diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..90c6aba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,64 @@ +name: Bug report +description: Report a reproducible problem with the SSH library. +title: "Bug: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a problem. Please include enough detail for maintainers to reproduce or reason about the issue. + - type: textarea + id: summary + attributes: + label: Summary + description: What went wrong? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Include a minimal code sample or command sequence when possible. + placeholder: | + 1. Configure client with ... + 2. Connect to ... + 3. Observe ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead? Include stack traces or logs if available. + render: text + validations: + required: true + - type: input + id: version + attributes: + label: Library version + placeholder: "0.2.2-SNAPSHOT" + validations: + required: true + - type: input + id: runtime + attributes: + label: Runtime + description: Kotlin/JDK/Android version, if relevant. + placeholder: "Kotlin 2.3.21, JDK 21" + validations: + required: false + - type: textarea + id: context + attributes: + label: Additional context + description: Server implementation, algorithms, platform, or anything else useful. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/compatibility-report.yml b/.github/ISSUE_TEMPLATE/compatibility-report.yml new file mode 100644 index 0000000..e278fcd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility-report.yml @@ -0,0 +1,59 @@ +name: Compatibility report +description: Report interoperability with a specific SSH server, algorithm, or client workflow. +title: "Compatibility: " +labels: [compatibility] +body: + - type: input + id: server + attributes: + label: SSH server + description: Server implementation and version. + placeholder: "OpenSSH_9.9p2" + validations: + required: true + - type: input + id: library-version + attributes: + label: Library version + placeholder: "0.2.2-SNAPSHOT" + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Connection setup + - Key exchange + - Host key verification + - Authentication + - Shell/session channel + - SFTP + - Port forwarding + - Agent forwarding + - Other + validations: + required: true + - type: textarea + id: algorithms + attributes: + label: Algorithms and configuration + description: Include negotiated algorithms, client config, auth methods, or server config if known. + render: text + validations: + required: false + - type: textarea + id: behavior + attributes: + label: Observed behavior + description: What works or fails? Include logs or packet-level details if available. + render: text + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect based on other clients, server docs, or protocol references? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ed83824 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/connectbot/cbssh/security/advisories/new + about: Please report vulnerabilities privately. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..f5a0245 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,33 @@ +name: Feature request +description: Suggest a new capability or API improvement. +title: "Feature: " +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do, and what is missing today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the API, behavior, algorithm, or workflow you would like. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Workarounds, other libraries, or different designs you considered. + validations: + required: false + - type: textarea + id: context + attributes: + label: Additional context + description: Relevant RFCs, OpenSSH behavior, server compatibility, or examples. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/release-branch.yml b/.github/ISSUE_TEMPLATE/release-branch.yml new file mode 100644 index 0000000..d6be6de --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release-branch.yml @@ -0,0 +1,34 @@ +name: Release branch +description: Create a maintenance release branch. +title: "Create release branch " +labels: [] +body: + - type: markdown + attributes: + value: | + This release branch automation is for maintainers only. Submissions from non-maintainers will be rejected by the workflow. + - type: input + id: maintenanceBranch + attributes: + label: Maintenance branch + description: Branch to create for maintenance releases. + placeholder: "release/1.1" + validations: + required: true + - type: input + id: sourceBranch + attributes: + label: Source branch + description: Branch to cut from. + placeholder: "main" + value: "main" + validations: + required: true + - type: input + id: sourceRef + attributes: + label: Source ref + description: Optional exact ref or SHA. Leave blank to use the source branch tip. + placeholder: "main" + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml new file mode 100644 index 0000000..53ba39d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release.yml @@ -0,0 +1,41 @@ +name: Release +description: Prepare and publish a library release. +title: "Release " +labels: [] +body: + - type: markdown + attributes: + value: | + This release automation is for maintainers only. Submissions from non-maintainers will be rejected by the workflow. + - type: input + id: releaseVersion + attributes: + label: Release version + description: Version to publish, without the v tag prefix. + placeholder: "1.0.4" + validations: + required: true + - type: input + id: nextVersion + attributes: + label: Next version + description: Next development version after the release. + placeholder: "1.0.5-SNAPSHOT" + validations: + required: true + - type: input + id: targetBranch + attributes: + label: Target branch + description: Branch to release from and fast-forward on publish. + placeholder: "main" + value: "main" + validations: + required: true + - type: textarea + id: releaseNotes + attributes: + label: Release notes + description: Optional text to include in the annotated tag message. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/security.md b/.github/ISSUE_TEMPLATE/security.md new file mode 100644 index 0000000..ef31498 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security.md @@ -0,0 +1,14 @@ +--- +name: Security report +about: Report a vulnerability or sensitive security issue. +title: "Security: " +labels: security +--- + +Please do not open a public issue for vulnerabilities or sensitive security reports. + +Use GitHub's private vulnerability reporting for this repository: + +https://github.com/connectbot/cbssh/security/advisories/new + +Contact the maintainers privately if private reporting is unavailable. diff --git a/.github/scripts/comment-issue-failure.sh b/.github/scripts/comment-issue-failure.sh new file mode 100644 index 0000000..8715ae4 --- /dev/null +++ b/.github/scripts/comment-issue-failure.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +log_file="$1" +workflow_name="$2" + +body_file="$(mktemp)" +redacted_log="$(mktemp)" +if [[ -s "${log_file}" ]]; then + cp "${log_file}" "${redacted_log}" + if [[ -n "${PUSH_TOKEN:-}" ]]; then + perl -0pi -e 'BEGIN { $s = $ENV{PUSH_TOKEN} // "" } if (length $s) { s/\Q$s\E//g }' "${redacted_log}" + fi + if [[ -n "${GH_TOKEN:-}" ]]; then + perl -0pi -e 'BEGIN { $s = $ENV{GH_TOKEN} // "" } if (length $s) { s/\Q$s\E//g }' "${redacted_log}" + fi +fi + +{ + echo "${workflow_name} failed." + echo + echo '```text' + if [[ -s "${redacted_log}" ]]; then + tail -n 80 "${redacted_log}" + else + echo "No script output was captured." + fi + echo '```' + echo + echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" +} >"${body_file}" + +gh issue comment "${ISSUE_NUMBER}" --body-file "${body_file}" diff --git a/.github/scripts/create-release-branch.sh b/.github/scripts/create-release-branch.sh new file mode 100644 index 0000000..b50404f --- /dev/null +++ b/.github/scripts/create-release-branch.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=.github/scripts/release-common.sh +source "${script_dir}/release-common.sh" + +require_maintainer + +maintenance_branch="$(extract_issue_field_line "Maintenance branch")" +source_branch="$(extract_issue_field_line "Source branch")" +source_ref="$(extract_issue_field_line "Source ref" || true)" + +validate_maintenance_branch "${maintenance_branch}" +validate_release_target_branch "${source_branch}" +if [[ -z "${source_ref}" ]]; then + source_ref="origin/${source_branch}" +elif [[ "${source_ref}" == "${source_branch}" ]]; then + source_ref="origin/${source_branch}" +fi +if [[ "${source_ref}" == -* ]]; then + echo "Source ref must not start with '-'." + exit 1 +fi + +require_missing_remote_branch "${maintenance_branch}" +require_remote_branch "${source_branch}" + +configure_git_credentials +git fetch origin "+refs/heads/${source_branch}:refs/remotes/origin/${source_branch}" --tags +git rev-parse --verify --end-of-options "${source_ref}^{commit}" >/dev/null +if ! git merge-base --is-ancestor -- "${source_ref}^{commit}" "origin/${source_branch}"; then + echo "Source ref ${source_ref} is not reachable from origin/${source_branch}." + exit 1 +fi +git push origin "${source_ref}^{commit}:refs/heads/${maintenance_branch}" +gh issue comment "${ISSUE_NUMBER}" --body "Created ${maintenance_branch} from ${source_ref}." diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh new file mode 100644 index 0000000..5005378 --- /dev/null +++ b/.github/scripts/prepare-release.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=.github/scripts/release-common.sh +source "${script_dir}/release-common.sh" + +require_maintainer + +release_version="$(extract_issue_field_line "Release version")" +next_version="$(extract_issue_field_line "Next version")" +target_branch="$(extract_issue_field_line "Target branch")" + +validate_release_version "${release_version}" +validate_next_version "${next_version}" +validate_release_target_branch "${target_branch}" + +tag_name="v${release_version}" +work_branch="release-work/${release_version}" + +require_missing_remote_tag "${tag_name}" +require_remote_branch "${target_branch}" +require_missing_remote_branch "${work_branch}" + +configure_git_author +configure_git_credentials +git fetch origin "+refs/heads/${target_branch}:refs/remotes/origin/${target_branch}" --tags +git checkout -B "${work_branch}" "origin/${target_branch}" + +./gradlew release \ + -Pcbssh.release.noPush=true \ + -Prelease.useAutomaticVersion=true \ + -Prelease.releaseVersion="${release_version}" \ + -Prelease.newVersion="${next_version}" + +git push origin "HEAD:refs/heads/${work_branch}" + +{ + echo "release_version=${release_version}" + echo "next_version=${next_version}" + echo "target_branch=${target_branch}" + echo "work_branch=${work_branch}" + echo "tag_name=${tag_name}" +} >>"${GITHUB_OUTPUT}" diff --git a/.github/scripts/publish-release.sh b/.github/scripts/publish-release.sh new file mode 100644 index 0000000..dbaba03 --- /dev/null +++ b/.github/scripts/publish-release.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=.github/scripts/release-common.sh +source "${script_dir}/release-common.sh" + +require_maintainer + +release_version="$(extract_issue_field_line "Release version")" +next_version="$(extract_issue_field_line "Next version")" +target_branch="$(extract_issue_field_line "Target branch")" +release_notes="$(extract_issue_field "Release notes" || true)" + +validate_release_version "${release_version}" +validate_next_version "${next_version}" +validate_release_target_branch "${target_branch}" + +tag_name="v${release_version}" +work_branch="release-work/${release_version}" + +require_missing_remote_tag "${tag_name}" + +pr_number="$(gh pr list \ + --head "${work_branch}" \ + --base "${target_branch}" \ + --state open \ + --json number \ + --jq '.[0].number // empty')" + +if [[ -z "${pr_number}" ]]; then + echo "No open release PR found for ${work_branch} into ${target_branch}." + exit 1 +fi + +pr_data="$(gh pr view "${pr_number}" --json baseRefName,headRefName,headRefOid,mergeable,mergeStateStatus,reviewDecision)" +actual_base="$(jq -r '.baseRefName' <<<"${pr_data}")" +actual_head="$(jq -r '.headRefName' <<<"${pr_data}")" +head_ref_oid="$(jq -r '.headRefOid' <<<"${pr_data}")" +mergeable="$(jq -r '.mergeable' <<<"${pr_data}")" +merge_state="$(jq -r '.mergeStateStatus' <<<"${pr_data}")" +review_decision="$(jq -r '.reviewDecision' <<<"${pr_data}")" + +if [[ "${actual_base}" != "${target_branch}" || "${actual_head}" != "${work_branch}" ]]; then + echo "Release PR does not match the issue target branch and work branch." + exit 1 +fi +if [[ "${mergeable}" != "MERGEABLE" ]]; then + echo "Release PR must be mergeable before publishing. Current mergeable state: ${mergeable}." + exit 1 +fi +if [[ "${merge_state}" != "CLEAN" ]]; then + echo "Release PR must have a clean merge state before publishing. Current merge state: ${merge_state}." + exit 1 +fi +if [[ "${review_decision}" != "APPROVED" ]]; then + echo "Release PR must be approved before publishing. Current review decision: ${review_decision}." + exit 1 +fi + +gh pr checks "${pr_number}" --required --fail-fast + +configure_git_author +configure_git_credentials +git fetch origin \ + "+refs/heads/${target_branch}:refs/remotes/origin/${target_branch}" \ + "+refs/heads/${work_branch}:refs/remotes/origin/${work_branch}" \ + --tags + +if [[ "$(git rev-parse "origin/${work_branch}")" != "${head_ref_oid}" ]]; then + echo "Release branch changed after PR checks were inspected." + exit 1 +fi + +release_commit="" +while read -r commit; do + if git show "${commit}:gradle.properties" | grep -q "^version=${release_version}$"; then + release_commit="${commit}" + break + fi +done < <(git rev-list --reverse "origin/${target_branch}..origin/${work_branch}") + +if [[ -z "${release_commit}" ]]; then + echo "Could not find release commit with version=${release_version}." + exit 1 +fi + +if ! git show "origin/${work_branch}:gradle.properties" | grep -q "^version=${next_version}$"; then + echo "Release branch tip does not contain version=${next_version}." + exit 1 +fi + +tag_message="$(mktemp)" +{ + echo "Release ${tag_name}" + if [[ -n "${release_notes}" ]]; then + echo + echo "${release_notes}" + fi +} >"${tag_message}" + +git tag -a "${tag_name}" "${release_commit}" -F "${tag_message}" +if [[ "$(git cat-file -t "${tag_name}")" != "tag" ]]; then + echo "${tag_name} is not an annotated tag." + exit 1 +fi + +git push --atomic --follow-tags origin "refs/remotes/origin/${work_branch}:refs/heads/${target_branch}" +gh issue comment "${ISSUE_NUMBER}" --body "Published ${tag_name} to ${target_branch}." diff --git a/.github/scripts/release-common.sh b/.github/scripts/release-common.sh new file mode 100644 index 0000000..749ef0d --- /dev/null +++ b/.github/scripts/release-common.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash + +set -euo pipefail + +require_maintainer() { + local actor="${RELEASE_ACTOR:-}" + local permission + + if [[ -z "${actor}" ]]; then + echo "Release automation requires RELEASE_ACTOR to be set." + exit 1 + fi + + permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${actor}/permission" --jq '.permission')" + + case "${permission}" in + admin|maintain) ;; + *) + echo "Release automation must be started by a maintainer with maintain or admin access. ${actor} has ${permission} permission." + exit 1 + ;; + esac +} + +extract_issue_field() { + local heading="$1" + awk -v heading="$heading" ' + $0 == "### " heading { found = 1; next } + found && /^### / { exit } + found { print } + ' <<<"${ISSUE_BODY:-}" | + sed '//d' | + awk ' + { lines[NR] = $0 } + END { + start = 1 + end = NR + while (start <= end && lines[start] ~ /^[[:space:]]*$/) { + start++ + } + while (end >= start && lines[end] ~ /^[[:space:]]*$/) { + end-- + } + for (i = start; i <= end; i++) { + print lines[i] + } + } + ' +} + +extract_issue_field_line() { + extract_issue_field "$1" | head -n 1 | tr -d '[:space:]' +} + +validate_release_version() { + local version="$1" + if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Release version must look like 1.2.3." + exit 1 + fi +} + +validate_next_version() { + local version="$1" + if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-SNAPSHOT$ ]]; then + echo "Next version must look like 1.2.4-SNAPSHOT." + exit 1 + fi +} + +validate_release_target_branch() { + local branch="$1" + if [[ ! "${branch}" =~ ^(main|release/[0-9]+\.[0-9]+)$ ]]; then + echo "Target branch must be main or release/." + exit 1 + fi +} + +validate_maintenance_branch() { + local branch="$1" + if [[ ! "${branch}" =~ ^release/[0-9]+\.[0-9]+$ ]]; then + echo "Maintenance branch must look like release/1.1." + exit 1 + fi +} + +require_remote_branch() { + local branch="$1" + if ! git ls-remote --exit-code --heads origin "refs/heads/${branch}" >/dev/null 2>&1; then + echo "Branch ${branch} does not exist." + exit 1 + fi +} + +require_missing_remote_branch() { + local branch="$1" + if git ls-remote --exit-code --heads origin "refs/heads/${branch}" >/dev/null 2>&1; then + echo "Branch ${branch} already exists." + exit 1 + fi +} + +require_missing_remote_tag() { + local tag="$1" + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "Tag ${tag} already exists." + exit 1 + fi +} + +configure_git_author() { + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +} + +configure_git_credentials() { + if [[ -z "${PUSH_TOKEN:-}" ]]; then + echo "PUSH_TOKEN is required for authenticated git pushes." + exit 1 + fi + + git config --global credential.helper store + { + printf "https://x-access-token:%s@github.com\n" "${PUSH_TOKEN}" + } >"${HOME}/.git-credentials" + chmod 0600 "${HOME}/.git-credentials" +} diff --git a/.github/scripts/upsert-release-pr.sh b/.github/scripts/upsert-release-pr.sh new file mode 100644 index 0000000..3245fa9 --- /dev/null +++ b/.github/scripts/upsert-release-pr.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -euo pipefail + +release_version="$1" +next_version="$2" +target_branch="$3" +work_branch="$4" +tag_name="$5" + +body_file="$(mktemp)" +cat >"${body_file}" <"${WORKDIR}/gradlew" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +{ + printf 'gradlew' + printf ' %q' "$@" + printf '\n' +} >>"${LOG_FILE}" +STUB + chmod +x "${WORKDIR}/gradlew" +} + +make_git_stub() { + cat >"${BIN_DIR}/git" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +{ + printf 'git' + printf ' %q' "$@" + printf '\n' +} >>"${LOG_FILE}" + +case "$1" in + ls-remote) + if [[ "$*" == *"refs/tags/v9.9.9"* ]]; then + exit 0 + fi + if [[ "$*" == *"refs/tags/"* || "$*" == *"refs/heads/missing"* || "$*" == *"refs/heads/release/1.1"* ]]; then + exit 2 + fi + if [[ "$*" == *"refs/heads/release-work/9.9.8"* ]]; then + exit 0 + fi + if [[ "$*" == *"refs/heads/release-work/"* ]]; then + exit 2 + fi + exit 0 + ;; + show) + case "$2" in + release-commit:gradle.properties) + echo "version=1.2.3" + ;; + refs/remotes/origin/release-work/1.2.3:gradle.properties|origin/release-work/1.2.3:gradle.properties) + echo "version=1.2.4-SNAPSHOT" + ;; + *) + echo "version=0.0.0-SNAPSHOT" + ;; + esac + ;; + rev-list) + echo "release-commit" + echo "next-commit" + ;; + cat-file) + echo "tag" + ;; + rev-parse) + if [[ "${2:-}" == "origin/release-work/1.2.3" ]]; then + echo "head-sha" + fi + exit 0 + ;; + merge-base) + exit 0 + ;; +esac +STUB + chmod +x "${BIN_DIR}/git" +} + +make_gh_stub() { + cat >"${BIN_DIR}/gh" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +{ + printf 'gh' + printf ' %q' "$@" + printf '\n' +} >>"${LOG_FILE}" + +if [[ "$1" == "api" ]]; then + echo "maintain" +elif [[ "$1 $2" == "pr list" ]]; then + echo "77" +elif [[ "$1 $2" == "pr view" ]]; then + echo '{"baseRefName":"main","headRefName":"release-work/1.2.3","headRefOid":"head-sha","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED"}' +fi +STUB + chmod +x "${BIN_DIR}/gh" +} + +release_issue_body() { + cat <<'EOF' +### Release version + +1.2.3 + +### Next version + +1.2.4-SNAPSHOT + +### Target branch + +main + +### Release notes + +Security fixes and release automation. + +Second paragraph stays separate. +EOF +} + +branch_issue_body() { + cat <<'EOF' +### Maintenance branch + +release/1.1 + +### Source branch + +main + +### Source ref + +EOF +} + +@test "release-common extracts issue form fields" { + export ISSUE_BODY + ISSUE_BODY="$(release_issue_body)" + + run bash -c 'source .github/scripts/release-common.sh; extract_issue_field_line "Release version"' + + [ "$status" -eq 0 ] + [ "$output" = "1.2.3" ] +} + +@test "release-common preserves internal blank lines in multi-line fields" { + export ISSUE_BODY + ISSUE_BODY="$(release_issue_body)" + + run bash -c 'source .github/scripts/release-common.sh; extract_issue_field "Release notes"' + + [ "$status" -eq 0 ] + [[ "$output" == $'Security fixes and release automation.\n\nSecond paragraph stays separate.' ]] +} + +@test "prepare-release creates a release-work branch with netrelease no-push mode" { + export ISSUE_BODY + ISSUE_BODY="$(release_issue_body)" + + run bash .github/scripts/prepare-release.sh + + [ "$status" -eq 0 ] + grep -F "gh api repos/connectbot/cbssh/collaborators/maintainer/permission --jq .permission" "${LOG_FILE}" + grep -F "git config --global credential.helper store" "${LOG_FILE}" + grep -F "git checkout -B release-work/1.2.3 origin/main" "${LOG_FILE}" + grep -F "gradlew release -Pcbssh.release.noPush=true -Prelease.useAutomaticVersion=true -Prelease.releaseVersion=1.2.3 -Prelease.newVersion=1.2.4-SNAPSHOT" "${LOG_FILE}" + grep -F "git push origin HEAD:refs/heads/release-work/1.2.3" "${LOG_FILE}" + grep -F "tag_name=v1.2.3" "${GITHUB_OUTPUT}" +} + +@test "prepare-release rejects an existing v-prefixed tag" { + export ISSUE_BODY + ISSUE_BODY="$(release_issue_body | sed 's/1.2.3/9.9.9/')" + + run bash .github/scripts/prepare-release.sh + + [ "$status" -ne 0 ] + [[ "$output" == *"Tag v9.9.9 already exists."* ]] +} + +@test "prepare-release rejects an existing release-work branch" { + export ISSUE_BODY + ISSUE_BODY="$(release_issue_body | sed 's/1.2.3/9.9.8/')" + + run bash .github/scripts/prepare-release.sh + + [ "$status" -ne 0 ] + [[ "$output" == *"Branch release-work/9.9.8 already exists."* ]] +} + +@test "publish-release creates an annotated v tag and atomic fast-forward push" { + export ISSUE_BODY + ISSUE_BODY="$(release_issue_body)" + + run bash .github/scripts/publish-release.sh + + [ "$status" -eq 0 ] + grep -F "gh pr checks 77 --required --fail-fast" "${LOG_FILE}" + grep -F "git config --global credential.helper store" "${LOG_FILE}" + grep -F "git tag -a v1.2.3 release-commit -F" "${LOG_FILE}" + grep -F "git push --atomic --follow-tags origin refs/remotes/origin/release-work/1.2.3:refs/heads/main" "${LOG_FILE}" + grep -F "gh issue comment 123 --body Published\ v1.2.3\ to\ main." "${LOG_FILE}" + grep -F "https://x-access-token:push-token@github.com" "${HOME}/.git-credentials" +} + +@test "create-release-branch cuts from the remote source branch tip by default" { + export ISSUE_BODY + ISSUE_BODY="$(branch_issue_body)" + + run bash .github/scripts/create-release-branch.sh + + [ "$status" -eq 0 ] + grep -F "git config --global credential.helper store" "${LOG_FILE}" + grep -F "git fetch origin +refs/heads/main:refs/remotes/origin/main --tags" "${LOG_FILE}" + grep -F "git rev-parse --verify --end-of-options origin/main\\^\\{commit\\}" "${LOG_FILE}" + grep -F "git merge-base --is-ancestor -- origin/main\\^\\{commit\\} origin/main" "${LOG_FILE}" + grep -F "git push origin origin/main\\^\\{commit\\}:refs/heads/release/1.1" "${LOG_FILE}" +} + +@test "create-release-branch rejects option-like source refs" { + export ISSUE_BODY + ISSUE_BODY="$(cat <<'EOF' +### Maintenance branch + +release/1.1 + +### Source branch + +main + +### Source ref + +--help +EOF +)" + + run bash .github/scripts/create-release-branch.sh + + [ "$status" -ne 0 ] + [[ "$output" == *"Source ref must not start with '-'."* ]] +} + +@test "create-release-branch rejects invalid maintenance branch names" { + export ISSUE_BODY + ISSUE_BODY="$(branch_issue_body | sed 's#release/1.1#main#')" + + run bash .github/scripts/create-release-branch.sh + + [ "$status" -ne 0 ] + [[ "$output" == *"Maintenance branch must look like release/1.1."* ]] +} + +@test "comment-issue-failure posts captured output and run URL" { + failure_log="${BATS_TEST_TMPDIR}/failure.log" + cat >"${failure_log}" <<'EOF' +Release version must look like 1.2.3. +push-token +EOF + + run bash .github/scripts/comment-issue-failure.sh "${failure_log}" "Prepare release" + + [ "$status" -eq 0 ] + grep -F "gh issue comment 123 --body-file" "${LOG_FILE}" + body_file="$(awk '/^gh issue comment 123 --body-file / { print $6 }' "${LOG_FILE}")" + grep -F "Prepare release failed." "${body_file}" + grep -F "Release version must look like 1.2.3." "${body_file}" + grep -F "" "${body_file}" + ! grep -F "push-token" "${body_file}" + grep -F "Run: https://github.com/connectbot/cbssh/actions/runs/456" "${body_file}" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b4c7af..3a85348 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,16 +2,42 @@ name: Continuous Integration on: push: - branches: ['*'] + branches: ['**'] + tags: ['v*.*.*'] pull_request: - branches: [main] + branches: [main, 'release/**'] schedule: - cron: '30 5 * * *' permissions: - contents: write + contents: read jobs: + workflow-tests: + name: Workflow scripts + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@be3be2260bc02bc3fbf94c5e2fed8b7964baf074 # v3.4.0 + with: + install: true + install_args: actionlint bats jq shellcheck + + - name: Check workflow syntax + run: mise exec -- actionlint + + - name: Check shell scripts + run: mise exec -- shellcheck -x .github/scripts/*.sh + + - name: Test release scripts + run: mise exec -- bats --print-output-on-failure .github/tests/release-scripts.bats + build: name: Build and test runs-on: ubuntu-latest @@ -36,7 +62,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - dependency-graph: generate-and-submit + dependency-graph: disabled validate-wrappers: true - name: Configure Docker mirror @@ -47,8 +73,40 @@ jobs: - name: Build with Gradle run: ./gradlew build koverXmlReport --info -PjdkVersion=${{ matrix.java }} - - name: Upload to Sonatype - if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.java == '17' + - name: Read project version + id: project-version + if: matrix.java == '17' + run: | + version="$(sed -n 's/^version=//p' gradle.properties)" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + + - name: Verify release tag + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && matrix.java == '17' + run: | + test "$(git cat-file -t "${GITHUB_REF_NAME}")" = "tag" + git fetch origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/heads/release/*:refs/remotes/origin/release/*" + tag_commit="$(git rev-list -n 1 "${GITHUB_REF_NAME}")" + git branch -r --contains "${tag_commit}" | grep -E 'origin/(main|release/[0-9]+\.[0-9]+)$' + + - name: Upload snapshot to Sonatype + if: > + github.event_name == 'push' && + matrix.java == '17' && + endsWith(steps.project-version.outputs.version, '-SNAPSHOT') && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) + run: | + ./gradlew publishToMavenCentral --no-configuration-cache -PmavenCentralUsername="${ORG_GRADLE_PROJECT_mavenCentralUsername}" -PmavenCentralPassword="${ORG_GRADLE_PROJECT_mavenCentralPassword}" + env: + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_GPG_KEYID }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} + + - name: Upload release to Sonatype + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && matrix.java == '17' run: | ./gradlew publishToMavenCentral --no-configuration-cache -PmavenCentralUsername="${ORG_GRADLE_PROJECT_mavenCentralUsername}" -PmavenCentralPassword="${ORG_GRADLE_PROJECT_mavenCentralPassword}" env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9dae418..d9a8d9d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: "CodeQL Advanced" on: push: - branches: [ "main" ] + branches: [ "main", "release/**" ] pull_request: - branches: [ "main" ] + branches: [ "main", "release/**" ] schedule: - cron: '22 22 * * 1' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..5d42ceb --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: Dependency Review +on: + pull_request: + branches: [main, 'release/**'] + +permissions: + contents: read + +jobs: + dependency-review: + name: Dependency review + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Dependency review + uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3 + with: + retry-on-snapshot-warnings: true + retry-on-snapshot-warnings-timeout: 600 diff --git a/.github/workflows/dependency-submission-submit-pr.yml b/.github/workflows/dependency-submission-submit-pr.yml new file mode 100644 index 0000000..0198b4d --- /dev/null +++ b/.github/workflows/dependency-submission-submit-pr.yml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: Submit pull request dependency graph +on: + workflow_run: + workflows: ['Dependency Submission'] + types: [completed] + +permissions: + actions: read + contents: write + +jobs: + submit-dependency-graph: + name: Submit pull request dependency graph + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download and submit dependency graph + uses: gradle/actions/dependency-submission@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + dependency-graph: download-and-submit diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml new file mode 100644 index 0000000..6932d85 --- /dev/null +++ b/.github/workflows/dependency-submission.yml @@ -0,0 +1,80 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: Dependency Submission +on: + push: + branches: [main, 'release/**'] + pull_request: + branches: [main, 'release/**'] + +jobs: + submit-dependency-graph: + name: Submit push dependency graph + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: '17' + + - name: Generate and submit dependency graph + uses: gradle/actions/dependency-submission@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + dependency-graph: generate-and-submit + + submit-pull-request-dependency-graph: + name: Submit pull request dependency graph + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: '17' + + - name: Generate and submit dependency graph + uses: gradle/actions/dependency-submission@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + dependency-graph: generate-and-submit + + generate-fork-dependency-graph: + name: Generate fork dependency graph + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: '17' + + - name: Generate dependency graph + uses: gradle/actions/dependency-submission@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + dependency-graph: generate-and-upload diff --git a/.github/workflows/release-branch.yml b/.github/workflows/release-branch.yml new file mode 100644 index 0000000..7b68fe1 --- /dev/null +++ b/.github/workflows/release-branch.yml @@ -0,0 +1,38 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: Create release branch +on: + issues: + types: [labeled] + +permissions: + contents: write + issues: write + +jobs: + branch: + name: Create maintenance branch + if: github.event.label.name == 'release:branch' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + GH_TOKEN: ${{ github.token }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + PUSH_TOKEN: ${{ github.token }} + RELEASE_ACTOR: ${{ github.event.sender.login }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Create branch + run: | + set -o pipefail + bash .github/scripts/create-release-branch.sh 2>&1 | tee "${RUNNER_TEMP}/release-branch.log" + + - name: Comment on failure + if: failure() + run: bash .github/scripts/comment-issue-failure.sh "${RUNNER_TEMP}/release-branch.log" "Create release branch" diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000..97b5e75 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,62 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: Prepare release +on: + issues: + types: [labeled] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + prepare: + name: Prepare release pull request + if: github.event.label.name == 'release:prepare' + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + GH_TOKEN: ${{ github.token }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + PUSH_TOKEN: ${{ github.token }} + RELEASE_ACTOR: ${{ github.event.sender.login }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Set up JDK + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + validate-wrappers: true + + - name: Prepare release branch + id: release + run: | + set -o pipefail + bash .github/scripts/prepare-release.sh 2>&1 | tee "${RUNNER_TEMP}/release-prepare.log" + + - name: Open or update release pull request + run: | + set -o pipefail + bash .github/scripts/upsert-release-pr.sh \ + "${{ steps.release.outputs.release_version }}" \ + "${{ steps.release.outputs.next_version }}" \ + "${{ steps.release.outputs.target_branch }}" \ + "${{ steps.release.outputs.work_branch }}" \ + "${{ steps.release.outputs.tag_name }}" \ + 2>&1 | tee -a "${RUNNER_TEMP}/release-prepare.log" + + - name: Comment on failure + if: failure() + run: bash .github/scripts/comment-issue-failure.sh "${RUNNER_TEMP}/release-prepare.log" "Prepare release" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..519541c --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,53 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: Publish release +on: + issues: + types: [labeled] + +permissions: + contents: read + issues: write + pull-requests: read + checks: read + statuses: read + +jobs: + publish: + name: Publish release tag and branch + if: github.event.label.name == 'release:publish' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + GH_TOKEN: ${{ github.token }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + RELEASE_ACTOR: ${{ github.event.sender.login }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Create release app token + id: release-app-token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + + - name: Publish release + env: + PUSH_TOKEN: ${{ steps.release-app-token.outputs.token }} + run: | + set -o pipefail + bash .github/scripts/publish-release.sh 2>&1 | tee "${RUNNER_TEMP}/release-publish.log" + + - name: Comment on failure + if: failure() + env: + PUSH_TOKEN: ${{ steps.release-app-token.outputs.token }} + run: bash .github/scripts/comment-issue-failure.sh "${RUNNER_TEMP}/release-publish.log" "Publish release" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..689fb23 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Reporting a Vulnerability + +Please do not report security vulnerabilities in public GitHub issues. + +Use GitHub's private vulnerability reporting for this repository: + +https://github.com/connectbot/cbssh/security/advisories/new + +If private vulnerability reporting is unavailable, contact the maintainers privately before publishing details. + +## What to Include + +When possible, include: + +- The affected library version or commit. +- A clear description of the vulnerability and impact. +- Steps to reproduce, proof-of-concept code, or relevant logs. +- Any known affected SSH servers, algorithms, authentication methods, or protocol messages. +- Whether the issue is already public or has been reported elsewhere. + +Please avoid including secrets, private keys, production credentials, or sensitive host details in reports. + +## Supported Versions + +Security fixes are generally provided for: + +- The current development line on `main`. +- Active maintenance branches named `release/`. + +Older versions may receive fixes when the impact is severe and a maintenance branch exists or can be reasonably created. + +## Disclosure Process + +Maintainers will review private reports and coordinate a fix before public disclosure when appropriate. Depending on severity and complexity, the fix may be released from `main`, an active `release/` branch, or both. + +Public advisories, release notes, and CVE requests will be handled after a fix is available or a coordinated disclosure date is reached. diff --git a/build.gradle.kts b/build.gradle.kts index 2bd2083..771b1e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,8 @@ * limitations under the License. */ +import net.researchgate.release.ReleaseExtension + plugins { alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.dokka) apply false @@ -39,6 +41,22 @@ dependencies { kover(project(":sshlib")) } +configure { + tagTemplate.set("v\${version}") + preTagCommitMessage.set("chore(release): ") + tagCommitMessage.set("Release ") + newVersionCommitMessage.set("chore(release): start ") + buildTasks.set(listOf("build")) + + git { + requireBranch.set("^(main|release/.+|release-work/.+)$") + commitVersionFileOnly.set(true) + if (providers.gradleProperty("cbssh.release.noPush").isPresent) { + pushToRemote.set(false) + } + } +} + sonar { properties { property("sonar.projectName", "ConnectBot SSH Library") @@ -67,8 +85,22 @@ sonar { } } +val spotlessRatchetRef = providers.environmentVariable("GITHUB_BASE_REF") + .map { "origin/$it" } + .orElse( + providers.environmentVariable("GITHUB_REF_NAME") + .map { refName -> + if (refName.startsWith("release/")) { + "origin/$refName" + } else { + "origin/main" + } + }, + ) + .getOrElse("origin/main") + spotless { - ratchetFrom = "origin/main" + ratchetFrom = spotlessRatchetRef kotlin { target("**/src/**/*.kt") diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..6221398 --- /dev/null +++ b/mise.toml @@ -0,0 +1,6 @@ +[tools] +actionlint = "1.7.12" +bats = "1.12.0" +java = "temurin-25.0.3+9.0.LTS" +jq = "1.8.1" +shellcheck = "0.11.0" diff --git a/settings.gradle.kts b/settings.gradle.kts index 2ec4a15..34d57c1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,4 @@ -rootProject.name = "ssh-proto" +rootProject.name = "cbssh" include(":sshlib") include(":testapp")