feat(ci): add PR batch test deployment workflow (#275)

* feat(ci): add PR batch test deployment workflow

* fix(ci): support local PR batch rehearsal

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
wowo 2026-04-09 18:45:14 +08:00 committed by GitHub
parent 0497f8654f
commit 689e698b89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 679 additions and 0 deletions

View file

@ -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}"

View file

@ -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.

128
scripts/deploy-test-runtime.sh Executable file
View file

@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/deploy-test-runtime.sh [options]
Options:
--host <host> Remote SSH host
--user <user> Remote SSH user. Default: skillhub-deploy
--port <port> Remote SSH port. Default: 22
--key-file <path> SSH private key for deployment
--deploy-tag <tag> Floating image tag to deploy
--immutable-tag <tag> Immutable image tag for traceability
--merged-sha <sha> Synthetic merge commit SHA
--pr-csv <list> Comma-separated PR numbers
--run-url <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

174
scripts/prepare-pr-batch.sh Executable file
View file

@ -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 <ref> Base branch to merge onto. Default: main
--deploy-channel <tag> 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 <<EOF
$(gh pr view "${pr}" \
--json state,baseRefName,isDraft,title,url \
--jq '[.state, .baseRefName, (.isDraft|tostring), .title, .url] | @tsv')
EOF
if [[ "${state}" != "OPEN" ]]; then
echo "PR #${pr} is not open (state=${state})" >&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

View file

@ -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 <tag> Floating image tag to deploy
--immutable-tag <tag> Immutable image tag for traceability
--merged-sha <sha> Synthetic merge commit SHA
--pr-csv <list> Comma-separated PR numbers
--run-url <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 <<METADATA
deployed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
deploy_tag=${deploy_tag}
immutable_tag=${immutable_tag}
merged_sha=${merged_sha}
pr_numbers=${pr_csv}
run_url=${run_url}
METADATA
docker compose --env-file .env.release -f compose.release.yml pull
docker compose --env-file .env.release -f compose.release.yml up -d
docker compose --env-file .env.release -f compose.release.yml ps
web_port="$(awk -F= '/^WEB_PORT=/{print $2}' .env.release | tail -n 1)"
if [[ -z "${web_port}" ]]; then
web_port="80"
fi
curl -fsS http://127.0.0.1:8080/actuator/health >/dev/null
curl -fsS "http://127.0.0.1:${web_port}/nginx-health" >/dev/null