diff --git a/.github/workflows/pr-batch-test-deploy.yml b/.github/workflows/pr-batch-test-deploy.yml new file mode 100644 index 00000000..f61cdef6 --- /dev/null +++ b/.github/workflows/pr-batch-test-deploy.yml @@ -0,0 +1,161 @@ +name: PR Batch Test Deploy + +on: + workflow_dispatch: + inputs: + pr_numbers: + description: "Comma/newline separated PR numbers to merge onto the base branch" + required: true + type: string + base_ref: + description: "Base branch to build from" + required: false + default: main + type: string + deploy_channel: + description: "Floating image tag used by the shared HK test machine" + required: false + default: manual-test-hk + type: string + +concurrency: + group: pr-batch-test-runtime + cancel-in-progress: false + +permissions: + contents: read + packages: write + pull-requests: read + +env: + DOCKER_PLATFORM: linux/amd64 + +jobs: + build-and-deploy: + name: Build And Deploy Manual Test Batch + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Ensure helper scripts are executable + run: chmod +x scripts/prepare-pr-batch.sh scripts/deploy-test-runtime.sh + + - name: Validate deploy secrets + env: + TEST_RUNTIME_SSH_HOST: ${{ secrets.TEST_RUNTIME_SSH_HOST }} + TEST_RUNTIME_SSH_KEY: ${{ secrets.TEST_RUNTIME_SSH_KEY }} + run: | + [[ -n "${TEST_RUNTIME_SSH_HOST}" ]] || { echo "::error::Missing secret TEST_RUNTIME_SSH_HOST"; exit 1; } + [[ -n "${TEST_RUNTIME_SSH_KEY}" ]] || { echo "::error::Missing secret TEST_RUNTIME_SSH_KEY"; exit 1; } + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Merge selected PRs onto base ref + id: batch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + scripts/prepare-pr-batch.sh \ + --pr-list "${{ inputs.pr_numbers }}" \ + --base-ref "${{ inputs.base_ref }}" \ + --deploy-channel "${{ inputs.deploy_channel }}" + + - name: Build and push backend image + uses: docker/build-push-action@v6 + with: + context: ./server + file: ./server/Dockerfile + platforms: ${{ env.DOCKER_PLATFORM }} + push: true + provenance: false + sbom: false + tags: | + ghcr.io/${{ github.repository_owner }}/skillhub-server:${{ steps.batch.outputs.deploy_tag }} + ghcr.io/${{ github.repository_owner }}/skillhub-server:${{ steps.batch.outputs.immutable_tag }} + cache-from: type=gha,scope=manual-test-server + cache-to: type=gha,mode=max,scope=manual-test-server + + - name: Build and push frontend image + uses: docker/build-push-action@v6 + with: + context: ./web + file: ./web/Dockerfile + platforms: ${{ env.DOCKER_PLATFORM }} + push: true + provenance: false + sbom: false + tags: | + ghcr.io/${{ github.repository_owner }}/skillhub-web:${{ steps.batch.outputs.deploy_tag }} + ghcr.io/${{ github.repository_owner }}/skillhub-web:${{ steps.batch.outputs.immutable_tag }} + cache-from: type=gha,scope=manual-test-web + cache-to: type=gha,mode=max,scope=manual-test-web + + - name: Build and push scanner image + uses: docker/build-push-action@v6 + with: + context: ./scanner + file: ./scanner/Dockerfile + platforms: ${{ env.DOCKER_PLATFORM }} + push: true + provenance: false + sbom: false + tags: | + ghcr.io/${{ github.repository_owner }}/skillhub-scanner:${{ steps.batch.outputs.deploy_tag }} + ghcr.io/${{ github.repository_owner }}/skillhub-scanner:${{ steps.batch.outputs.immutable_tag }} + cache-from: type=gha,scope=manual-test-scanner + cache-to: type=gha,mode=max,scope=manual-test-scanner + + - name: Prepare deploy key + id: ssh + env: + TEST_RUNTIME_SSH_KEY: ${{ secrets.TEST_RUNTIME_SSH_KEY }} + run: | + key_file="${RUNNER_TEMP}/test-runtime.key" + printf '%s\n' "${TEST_RUNTIME_SSH_KEY}" > "${key_file}" + chmod 600 "${key_file}" + echo "key_file=${key_file}" >> "${GITHUB_OUTPUT}" + + - name: Deploy batch images to HK test runtime + env: + TEST_RUNTIME_SSH_HOST: ${{ secrets.TEST_RUNTIME_SSH_HOST }} + TEST_RUNTIME_SSH_USER: ${{ secrets.TEST_RUNTIME_SSH_USER }} + TEST_RUNTIME_SSH_PORT: ${{ secrets.TEST_RUNTIME_SSH_PORT }} + run: | + ssh_port="${TEST_RUNTIME_SSH_PORT:-22}" + ssh_user="${TEST_RUNTIME_SSH_USER:-skillhub-deploy}" + scripts/deploy-test-runtime.sh \ + --host "${TEST_RUNTIME_SSH_HOST}" \ + --user "${ssh_user}" \ + --port "${ssh_port}" \ + --key-file "${{ steps.ssh.outputs.key_file }}" \ + --deploy-tag "${{ steps.batch.outputs.deploy_tag }}" \ + --immutable-tag "${{ steps.batch.outputs.immutable_tag }}" \ + --merged-sha "${{ steps.batch.outputs.merged_sha }}" \ + --pr-csv "${{ steps.batch.outputs.pr_csv }}" \ + --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + - name: Publish final summary + run: | + { + echo "### HK manual test runtime updated" + echo + echo "- URL: \`https://skill.xf-yun.com.cn\`" + echo "- Base ref: \`${{ steps.batch.outputs.base_ref }}\`" + echo "- Floating tag: \`${{ steps.batch.outputs.deploy_tag }}\`" + echo "- Immutable tag: \`${{ steps.batch.outputs.immutable_tag }}\`" + echo "- Merged SHA: \`${{ steps.batch.outputs.merged_sha }}\`" + echo "- PR list: \`${{ steps.batch.outputs.pr_csv }}\`" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/docs/pr-batch-test-runtime.md b/docs/pr-batch-test-runtime.md new file mode 100644 index 00000000..6f6d1c88 --- /dev/null +++ b/docs/pr-batch-test-runtime.md @@ -0,0 +1,81 @@ +# PR Batch Test Runtime + +This repository includes a manual GitHub Actions workflow that builds a +synthetic test image set from multiple PRs and deploys it to the shared +Hong Kong manual-test machine. + +Workflow file: + +- `.github/workflows/pr-batch-test-deploy.yml` + +## What the workflow does + +When you trigger the workflow manually, it: + +1. checks out the repository and fetches the selected base branch +2. parses the PR list you provide and deduplicates it while preserving order +3. verifies that every PR is still open and targets the chosen base branch +4. merges the selected PR heads onto the base branch in the exact order you supplied +5. fails fast if any PR conflicts with the base branch or with an earlier PR in the batch +6. builds `server`, `web`, and `scanner` images for `linux/amd64` +7. pushes both a floating tag and an immutable tag to GHCR +8. SSHes into the HK test machine as a dedicated deploy user +9. calls a root-owned deployment wrapper through `sudo` +10. updates `/opt/skillhub-runtime/.env.release` and runs `docker compose pull && docker compose up -d` + +The floating tag is the shared environment channel. By default it is +`manual-test-hk`. Each run also pushes an immutable tag for traceability: + +- floating tag example: `manual-test-hk` +- immutable tag example: `manual-test-hk-128-3d4a8e7f9a1b` + +The runtime always deploys the floating tag, so the same test URL keeps +working while still letting maintainers look up the exact image version +used by a given run. + +## Required GitHub secrets + +Add these repository or environment secrets before using the workflow: + +- `TEST_RUNTIME_SSH_HOST`: test machine hostname or IP +- `TEST_RUNTIME_SSH_KEY`: private key content used by GitHub Actions + +Optional secrets: + +- `TEST_RUNTIME_SSH_USER`: defaults to `skillhub-deploy` +- `TEST_RUNTIME_SSH_PORT`: defaults to `22` + +The remote machine should expose a root-owned deployment command at: + +- `/usr/local/bin/skillhub-test-deploy` + +The dedicated deploy user is expected to have passwordless sudo access to +that command only. + +## Recommended usage + +Open the workflow in GitHub Actions and fill in: + +- `pr_numbers`: a comma-separated or newline-separated list such as `123, 124, 130` +- `base_ref`: usually `main` +- `deploy_channel`: keep the default `manual-test-hk` for the shared test machine + +The merge order matters. If PR `124` depends on `123`, list `123` first. + +## Runtime metadata on the server + +After deployment, the workflow writes a small metadata file here: + +- `/opt/skillhub-runtime/manual-test-deployment.txt` + +It records: + +- deploy time +- floating tag +- immutable tag +- merged synthetic SHA +- PR list +- GitHub Actions run URL + +This makes it easy for testers and maintainers to confirm which batch is +currently deployed. diff --git a/scripts/deploy-test-runtime.sh b/scripts/deploy-test-runtime.sh new file mode 100755 index 00000000..47a2f3cc --- /dev/null +++ b/scripts/deploy-test-runtime.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/deploy-test-runtime.sh [options] + +Options: + --host Remote SSH host + --user Remote SSH user. Default: skillhub-deploy + --port Remote SSH port. Default: 22 + --key-file SSH private key for deployment + --deploy-tag Floating image tag to deploy + --immutable-tag Immutable image tag for traceability + --merged-sha Synthetic merge commit SHA + --pr-csv Comma-separated PR numbers + --run-url GitHub Actions run URL +EOF +} + +ssh_host="" +ssh_user="skillhub-deploy" +ssh_port="22" +ssh_key_file="" +deploy_tag="" +immutable_tag="" +merged_sha="" +pr_csv="" +run_url="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) + [[ $# -ge 2 ]] || { echo "Missing value for --host" >&2; exit 1; } + ssh_host="$2" + shift 2 + ;; + --user) + [[ $# -ge 2 ]] || { echo "Missing value for --user" >&2; exit 1; } + ssh_user="$2" + shift 2 + ;; + --port) + [[ $# -ge 2 ]] || { echo "Missing value for --port" >&2; exit 1; } + ssh_port="$2" + shift 2 + ;; + --key-file) + [[ $# -ge 2 ]] || { echo "Missing value for --key-file" >&2; exit 1; } + ssh_key_file="$2" + shift 2 + ;; + --deploy-tag) + [[ $# -ge 2 ]] || { echo "Missing value for --deploy-tag" >&2; exit 1; } + deploy_tag="$2" + shift 2 + ;; + --immutable-tag) + [[ $# -ge 2 ]] || { echo "Missing value for --immutable-tag" >&2; exit 1; } + immutable_tag="$2" + shift 2 + ;; + --merged-sha) + [[ $# -ge 2 ]] || { echo "Missing value for --merged-sha" >&2; exit 1; } + merged_sha="$2" + shift 2 + ;; + --pr-csv) + [[ $# -ge 2 ]] || { echo "Missing value for --pr-csv" >&2; exit 1; } + pr_csv="$2" + shift 2 + ;; + --run-url) + [[ $# -ge 2 ]] || { echo "Missing value for --run-url" >&2; exit 1; } + run_url="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unsupported argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "${ssh_host}" ]] || { echo "--host is required" >&2; exit 1; } +[[ -n "${ssh_key_file}" ]] || { echo "--key-file is required" >&2; exit 1; } +[[ -n "${deploy_tag}" ]] || { echo "--deploy-tag is required" >&2; exit 1; } +[[ -n "${immutable_tag}" ]] || { echo "--immutable-tag is required" >&2; exit 1; } + +ssh_opts=( + -i "${ssh_key_file}" + -o BatchMode=yes + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=accept-new + -o ServerAliveInterval=15 + -o ServerAliveCountMax=3 + -o TCPKeepAlive=yes + -o ConnectTimeout=10 + -p "${ssh_port}" +) + +ssh "${ssh_opts[@]}" "${ssh_user}@${ssh_host}" bash -s -- \ + "${deploy_tag}" \ + "${immutable_tag}" \ + "${merged_sha}" \ + "${pr_csv}" \ + "${run_url}" <<'EOF' +set -euo pipefail + +deploy_tag="$1" +immutable_tag="$2" +merged_sha="$3" +pr_csv="$4" +run_url="${5:-}" + +sudo /usr/local/bin/skillhub-test-deploy \ + --deploy-tag "${deploy_tag}" \ + --immutable-tag "${immutable_tag}" \ + --merged-sha "${merged_sha}" \ + --pr-csv "${pr_csv}" \ + --run-url "${run_url}" +EOF diff --git a/scripts/prepare-pr-batch.sh b/scripts/prepare-pr-batch.sh new file mode 100755 index 00000000..0b5f56a3 --- /dev/null +++ b/scripts/prepare-pr-batch.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/prepare-pr-batch.sh --pr-list "123,456" [options] + +Options: + --base-ref Base branch to merge onto. Default: main + --deploy-channel Floating image tag for the shared test runtime. + Default: manual-test-hk +EOF +} + +base_ref="main" +deploy_channel="manual-test-hk" +pr_input="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --base-ref) + [[ $# -ge 2 ]] || { echo "Missing value for --base-ref" >&2; exit 1; } + base_ref="$2" + shift 2 + ;; + --deploy-channel) + [[ $# -ge 2 ]] || { echo "Missing value for --deploy-channel" >&2; exit 1; } + deploy_channel="$2" + shift 2 + ;; + --pr-list) + [[ $# -ge 2 ]] || { echo "Missing value for --pr-list" >&2; exit 1; } + pr_input="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unsupported argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +: "${GH_TOKEN:?GH_TOKEN is required}" + +if [[ -z "${pr_input}" ]]; then + echo "--pr-list is required" >&2 + exit 1 +fi + +normalized_input="$(printf '%s' "${pr_input}" | tr ',;\r\n\t' ' ')" + +declare -a pr_numbers=() + +for token in ${normalized_input}; do + if [[ ! "${token}" =~ ^[0-9]+$ ]]; then + echo "Invalid PR number: ${token}" >&2 + exit 1 + fi + + already_seen=false + if [[ "${#pr_numbers[@]}" -gt 0 ]]; then + for existing in "${pr_numbers[@]}"; do + if [[ "${existing}" == "${token}" ]]; then + already_seen=true + break + fi + done + fi + + if [[ "${already_seen}" == "true" ]]; then + continue + fi + + pr_numbers+=("${token}") +done + +if [[ "${#pr_numbers[@]}" -eq 0 ]]; then + echo "No PR numbers were parsed from --pr-list" >&2 + exit 1 +fi + +sanitized_channel="$( + printf '%s' "${deploy_channel}" | + tr '[:upper:]' '[:lower:]' | + sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g' +)" + +if [[ -z "${sanitized_channel}" ]]; then + echo "Deploy channel resolved to an empty tag" >&2 + exit 1 +fi + +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + +git fetch --no-tags origin "${base_ref}" +git checkout -B manual-test-batch "origin/${base_ref}" + +summary_file="${RUNNER_TEMP:-/tmp}/manual-test-batch-summary.md" +current_pr="" +trap 'status=$?; if [[ $status -ne 0 && -n "${current_pr}" ]]; then echo "Failed while merging PR #${current_pr}" >&2; fi' EXIT + +{ + echo "### Manual Test Batch" + echo + echo "- Base ref: \`${base_ref}\`" + echo "- Deploy channel: \`${sanitized_channel}\`" + echo "- Selected PRs:" +} > "${summary_file}" + +for pr in "${pr_numbers[@]}"; do + current_pr="${pr}" + + IFS=$'\t' read -r state pr_base is_draft title url <&2 + exit 1 + fi + + if [[ "${pr_base}" != "${base_ref}" ]]; then + echo "PR #${pr} targets ${pr_base}, expected ${base_ref}" >&2 + exit 1 + fi + + git fetch --no-tags origin "pull/${pr}/head:refs/remotes/origin/manual-test-pr-${pr}" + git merge --no-ff --no-edit \ + -m "Merge PR #${pr} for manual test batch" \ + "refs/remotes/origin/manual-test-pr-${pr}" + + if [[ "${is_draft}" == "true" ]]; then + title="${title} [draft]" + fi + + echo " - #${pr} ${title} (${url})" >> "${summary_file}" +done + +merged_sha="$(git rev-parse HEAD)" +short_sha="$(git rev-parse --short=12 HEAD)" +run_token="${GITHUB_RUN_NUMBER:-manual}" +immutable_tag="${sanitized_channel}-${run_token}-${short_sha}" +pr_csv="$(IFS=,; echo "${pr_numbers[*]}")" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "base_ref=${base_ref}" + echo "deploy_tag=${sanitized_channel}" + echo "immutable_tag=${immutable_tag}" + echo "merged_sha=${merged_sha}" + echo "short_sha=${short_sha}" + echo "pr_csv=${pr_csv}" + echo "summary_file=${summary_file}" + } >> "${GITHUB_OUTPUT}" +fi + +{ + echo "- Merged SHA: \`${merged_sha}\`" + echo "- Floating tag: \`${sanitized_channel}\`" + echo "- Immutable tag: \`${immutable_tag}\`" +} >> "${summary_file}" + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + cat "${summary_file}" >> "${GITHUB_STEP_SUMMARY}" +fi diff --git a/scripts/skillhub-test-deploy-remote.sh b/scripts/skillhub-test-deploy-remote.sh new file mode 100644 index 00000000..39663624 --- /dev/null +++ b/scripts/skillhub-test-deploy-remote.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: /usr/local/bin/skillhub-test-deploy [options] + +Options: + --deploy-tag Floating image tag to deploy + --immutable-tag Immutable image tag for traceability + --merged-sha Synthetic merge commit SHA + --pr-csv Comma-separated PR numbers + --run-url GitHub Actions run URL +EOF +} + +runtime_dir="/opt/skillhub-runtime" +deploy_tag="" +immutable_tag="" +merged_sha="" +pr_csv="" +run_url="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --deploy-tag) + [[ $# -ge 2 ]] || { echo "Missing value for --deploy-tag" >&2; exit 1; } + deploy_tag="$2" + shift 2 + ;; + --immutable-tag) + [[ $# -ge 2 ]] || { echo "Missing value for --immutable-tag" >&2; exit 1; } + immutable_tag="$2" + shift 2 + ;; + --merged-sha) + [[ $# -ge 2 ]] || { echo "Missing value for --merged-sha" >&2; exit 1; } + merged_sha="$2" + shift 2 + ;; + --pr-csv) + [[ $# -ge 2 ]] || { echo "Missing value for --pr-csv" >&2; exit 1; } + pr_csv="$2" + shift 2 + ;; + --run-url) + [[ $# -ge 2 ]] || { echo "Missing value for --run-url" >&2; exit 1; } + run_url="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unsupported argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "${deploy_tag}" ]] || { echo "--deploy-tag is required" >&2; exit 1; } +[[ -n "${immutable_tag}" ]] || { echo "--immutable-tag is required" >&2; exit 1; } + +if [[ ! "${deploy_tag}" =~ ^[a-z0-9._-]+$ ]]; then + echo "Invalid deploy tag: ${deploy_tag}" >&2 + exit 1 +fi + +if [[ ! "${immutable_tag}" =~ ^[a-z0-9._-]+$ ]]; then + echo "Invalid immutable tag: ${immutable_tag}" >&2 + exit 1 +fi + +if [[ -n "${merged_sha}" && ! "${merged_sha}" =~ ^[0-9a-f]{7,64}$ ]]; then + echo "Invalid merged SHA: ${merged_sha}" >&2 + exit 1 +fi + +if [[ -n "${pr_csv}" && ! "${pr_csv}" =~ ^[0-9]+(,[0-9]+)*$ ]]; then + echo "Invalid PR list: ${pr_csv}" >&2 + exit 1 +fi + +if [[ -n "${run_url}" && ! "${run_url}" =~ ^https://github\.com/.+/actions/runs/[0-9]+$ ]]; then + echo "Invalid run URL: ${run_url}" >&2 + exit 1 +fi + +set_env_value() { + key="$1" + value="$2" + tmp=".env.release.tmp" + + if grep -q "^${key}=" .env.release; then + sed "s|^${key}=.*|${key}=${value}|" .env.release > "${tmp}" + else + cp .env.release "${tmp}" + printf '%s=%s\n' "${key}" "${value}" >> "${tmp}" + fi + + mv "${tmp}" .env.release +} + +cd "${runtime_dir}" + +test -f .env.release +test -f compose.release.yml + +cp .env.release ".env.release.bak.$(date +%Y%m%d%H%M%S)" + +set_env_value "SKILLHUB_VERSION" "${deploy_tag}" + +cat > manual-test-deployment.txt </dev/null +curl -fsS "http://127.0.0.1:${web_port}/nginx-health" >/dev/null