feat: 增加可复用部署工作流工具

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gouki
2026-07-15 21:49:27 +08:00
co-authored by Cursor
commit ec0f2aab5a
9 changed files with 523 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 1.0.0 - 2026-07-15
- Add a path-filtered Laravel verification and 1Panel deployment workflow.
- Add atomic Laravel release deployment with health checks and code rollback.
- Add a path-filtered Taro build and WeChat preview workflow.
- Add secure temporary-key handling and preview QR artifacts.
+30
View File
@@ -0,0 +1,30 @@
# Gitea Deployment Toolkit
Versioned Gitea Actions templates and composite actions for:
- Laravel deployment to a 1Panel PHP 8.2 runtime
- Taro WeChat Mini Program preview publishing
## Compatibility
Gitea 1.22 does not provide Scoped Workflows or the `concurrency` key. Copy a workflow from `workflows/` into the consuming repository's `.gitea/workflows/` directory, then reference the versioned actions in this repository. The Laravel action uses a host `flock` lock to serialize release changes.
Gitea 1.22 may automatically cancel an older push workflow when a newer commit reaches the same branch. Upgrade to Gitea 1.26 or newer before relying on configurable workflow concurrency. The deployment script traps termination and restores the previous code link, but an infrastructure-level forced kill can still interrupt a migration.
## Versioning
Consumers should reference the stable major tag:
```yaml
uses: https://gitea.neatcn.com/pets/deployment-toolkit/actions/laravel-release@v1
```
Create immutable release tags such as `v1.0.0`, then move `v1` only after validation.
## Security
- Store credentials in consuming-repository Secrets.
- Keep production `.env` files on the target server.
- Restrict production Runner labels to trusted repositories.
- Never expose a Docker socket to pull-request jobs.
- Pin third-party actions after the first successful bootstrap.
+45
View File
@@ -0,0 +1,45 @@
name: Laravel 1Panel Release
description: Atomically deploy a Laravel release to a 1Panel PHP container.
inputs:
source-dir:
description: Repository-relative Laravel source directory.
required: true
deploy-root:
description: Host deployment root.
required: true
container-deploy-root:
description: Deployment root as mounted inside the PHP container.
required: true
php-container:
description: 1Panel PHP container name.
required: true
healthcheck-url:
description: HTTPS Laravel health-check URL.
required: true
backup-command:
description: Server-side database backup command.
required: true
release-id:
description: Unique release identifier.
required: true
keep-releases:
description: Number of releases to retain.
required: false
default: "5"
runs:
using: composite
steps:
- name: Deploy release
shell: bash
env:
SOURCE_DIR: ${{ gitea.workspace }}/${{ inputs.source-dir }}
DEPLOY_ROOT: ${{ inputs.deploy-root }}
CONTAINER_DEPLOY_ROOT: ${{ inputs.container-deploy-root }}
PHP_CONTAINER: ${{ inputs.php-container }}
HEALTHCHECK_URL: ${{ inputs.healthcheck-url }}
BACKUP_COMMAND: ${{ inputs.backup-command }}
RELEASE_ID: ${{ inputs.release-id }}
KEEP_RELEASES: ${{ inputs.keep-releases }}
run: '"$GITHUB_ACTION_PATH/deploy-release.sh"'
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
set -Eeuo pipefail
required_variables=(
SOURCE_DIR
DEPLOY_ROOT
CONTAINER_DEPLOY_ROOT
PHP_CONTAINER
HEALTHCHECK_URL
BACKUP_COMMAND
)
for variable_name in "${required_variables[@]}"; do
test -n "${!variable_name:-}" || {
printf 'ERROR: %s is required.\n' "$variable_name" >&2
exit 1
}
done
release_id="${RELEASE_ID:-$(date -u +%Y%m%d%H%M%S)}"
keep_releases="${KEEP_RELEASES:-5}"
php_user="${PHP_RUNTIME_USER:-1000:1000}"
dry_run="${DRY_RUN:-false}"
host_release="$DEPLOY_ROOT/releases/$release_id"
container_release="$CONTAINER_DEPLOY_ROOT/releases/$release_id"
previous_release=""
maintenance_enabled=false
run() {
if test "$dry_run" = "true"; then
printf 'DRY-RUN:'
printf ' %q' "$@"
printf '\n'
return 0
fi
"$@"
}
artisan() {
run docker exec --user "$php_user" --workdir "$container_release" \
"$PHP_CONTAINER" php artisan "$@"
}
restore_previous_release() {
if test "$dry_run" != "true" && test -n "$previous_release"; then
ln -s "$previous_release" "$DEPLOY_ROOT/.current.rollback"
mv -Tf "$DEPLOY_ROOT/.current.rollback" "$DEPLOY_ROOT/current"
fi
}
recover() {
exit_code=$?
if test "$exit_code" -ne 0; then
printf 'Deployment failed; restoring the previous code release.\n' >&2
restore_previous_release
fi
if test "$maintenance_enabled" = "true" && test -L "$DEPLOY_ROOT/current"; then
docker exec --user "$php_user" \
--workdir "$CONTAINER_DEPLOY_ROOT/current" \
"$PHP_CONTAINER" php artisan up >/dev/null 2>&1 || true
fi
exit "$exit_code"
}
trap recover EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
test -d "$SOURCE_DIR" || {
printf 'ERROR: Source directory does not exist: %s\n' "$SOURCE_DIR" >&2
exit 1
}
test -f "$DEPLOY_ROOT/shared/.env" || {
printf 'ERROR: Shared production .env does not exist.\n' >&2
exit 1
}
test -d "$DEPLOY_ROOT/shared/storage" || {
printf 'ERROR: Shared storage directory does not exist.\n' >&2
exit 1
}
command -v flock >/dev/null 2>&1 || {
printf 'ERROR: flock is required on the production host.\n' >&2
exit 1
}
exec 9>"$DEPLOY_ROOT/.deploy.lock"
flock -w 600 9 || {
printf 'ERROR: Another deployment still holds the production lock.\n' >&2
exit 1
}
if test -L "$DEPLOY_ROOT/current"; then
previous_release="$(readlink "$DEPLOY_ROOT/current")"
if test "$dry_run" != "true"; then
docker exec --user "$php_user" \
--workdir "$CONTAINER_DEPLOY_ROOT/current" \
"$PHP_CONTAINER" php artisan down --retry=30
maintenance_enabled=true
fi
fi
run mkdir -p "$host_release"
run rsync -a --delete \
--exclude='.env' \
--exclude='node_modules' \
--exclude='storage' \
--exclude='vendor' \
"$SOURCE_DIR/" "$host_release/"
run ln -s ../../shared/.env "$host_release/.env"
run ln -s ../../shared/storage "$host_release/storage"
run docker exec --user "$php_user" --workdir "$container_release" \
"$PHP_CONTAINER" composer install \
--no-dev --no-interaction --prefer-dist --optimize-autoloader
if test "$dry_run" != "true"; then
bash -c "$BACKUP_COMMAND"
fi
artisan migrate --force
artisan storage:link
artisan optimize:clear
artisan config:cache
artisan route:cache
artisan view:cache
run ln -s "releases/$release_id" "$DEPLOY_ROOT/.current.next"
run mv -Tf "$DEPLOY_ROOT/.current.next" "$DEPLOY_ROOT/current"
if test "$dry_run" != "true"; then
docker exec "$PHP_CONTAINER" kill -USR2 1
docker exec --user "$php_user" \
--workdir "$CONTAINER_DEPLOY_ROOT/current" \
"$PHP_CONTAINER" php artisan up
maintenance_enabled=false
curl --fail --silent --show-error \
--retry 6 --retry-delay 5 --retry-connrefused \
"$HEALTHCHECK_URL" >/dev/null
active_target="$(readlink "$DEPLOY_ROOT/current")"
mapfile -t old_releases < <(
ls -1dt "$DEPLOY_ROOT"/releases/* 2>/dev/null |
awk -v keep="$keep_releases" -v active="$DEPLOY_ROOT/$active_target" '
$0 != active { seen++; if (seen >= keep) print }
'
)
if test "${#old_releases[@]}" -gt 0; then
rm -rf -- "${old_releases[@]}"
fi
fi
trap - EXIT
printf 'Deployment succeeded: %s\n' "$release_id"
+41
View File
@@ -0,0 +1,41 @@
name: WeChat Mini Program Preview
description: Publish a WeChat Mini Program preview and generate a QR code.
inputs:
project-path:
description: Repository-relative directory containing project.config.json.
required: true
appid:
description: WeChat Mini Program AppID.
required: true
robot:
description: WeChat CI robot number from 1 to 30.
required: false
default: "1"
private-key-path:
description: Temporary code-upload key path.
required: true
qrcode-output:
description: Destination PNG path.
required: true
version-label:
description: Traceable commit or release label.
required: true
runs:
using: composite
steps:
- name: Publish preview
shell: bash
working-directory: ${{ inputs.project-path }}
env:
MINIAPP_APPID: ${{ inputs.appid }}
MINIAPP_ROBOT: ${{ inputs.robot }}
WECHAT_CI_PRIVATE_KEY_PATH: ${{ inputs.private-key-path }}
MINIAPP_QR_OUTPUT: ${{ inputs.qrcode-output }}
MINIAPP_VERSION_LABEL: ${{ inputs.version-label }}
run: |
temporary_script=".wechat-preview.cjs"
cp "$GITHUB_ACTION_PATH/preview.cjs" "$temporary_script"
trap 'rm -f "$temporary_script"' EXIT
node "$temporary_script"
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const process = require("node:process");
const ci = require("miniprogram-ci");
function requireEnvironment(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
async function main() {
const projectPath = process.cwd();
const privateKeyPath = path.resolve(
requireEnvironment("WECHAT_CI_PRIVATE_KEY_PATH"),
);
const qrcodeOutputDest = path.resolve(
requireEnvironment("MINIAPP_QR_OUTPUT"),
);
const appid = requireEnvironment("MINIAPP_APPID");
const robot = Number.parseInt(process.env.MINIAPP_ROBOT ?? "1", 10);
const versionLabel = requireEnvironment("MINIAPP_VERSION_LABEL");
if (!fs.existsSync(path.join(projectPath, "project.config.json"))) {
throw new Error("The working directory must contain project.config.json.");
}
if (!fs.existsSync(privateKeyPath)) {
throw new Error("The WeChat code-upload key file does not exist.");
}
if (!Number.isInteger(robot) || robot < 1 || robot > 30) {
throw new Error("MINIAPP_ROBOT must be an integer from 1 to 30.");
}
const project = new ci.Project({
appid,
type: "miniProgram",
projectPath,
privateKeyPath,
ignores: ["node_modules/**/*", ".git/**/*"],
});
await ci.preview({
project,
desc: `Automated preview ${versionLabel}`.slice(0, 50),
robot,
setting: {
es6: true,
minify: true,
autoPrefixWXSS: true,
},
qrcodeFormat: "image",
qrcodeOutputDest,
onProgressUpdate: (progress) => {
if (typeof progress === "string") {
console.log(progress);
}
},
});
console.log(`Preview QR code created: ${qrcodeOutputDest}`);
}
main().catch((error) => {
console.error(`Mini Program preview failed: ${error.message}`);
process.exitCode = 1;
});
+38
View File
@@ -0,0 +1,38 @@
# Consuming Repository Configuration
## Laravel deployment variables
```text
SERVER_DEPLOY_ROOT=/opt/1panel/apps/openresty/openresty/www/sites/example.com/index
SERVER_CONTAINER_DEPLOY_ROOT=/www/sites/example.com/index
SERVER_PHP_CONTAINER=php82-example
SERVER_HEALTHCHECK_URL=https://example.com/up
```
Laravel deployment secret:
```text
SERVER_BACKUP_COMMAND=/usr/local/sbin/backup-example-database
```
The command should be a root-owned server-side wrapper. Do not put database passwords directly in a workflow.
## Mini Program variables
```text
MINIAPP_APPID=wx0000000000000000
MINIAPP_API_URL=https://example.com/api
MINIAPP_ROBOT=1
```
Mini Program secret:
```text
WECHAT_CI_PRIVATE_KEY=<complete code-upload private key>
```
Add the Runner's fixed public egress IP to the WeChat code-upload whitelist.
## Production Runner
Register a repository-scoped host Runner label named `production`. Pull-request workflows must not use this label or access production secrets.
+80
View File
@@ -0,0 +1,80 @@
name: Deploy Laravel Server
on:
push:
branches:
- main
paths:
- "server/**"
- ".nvmrc"
- ".gitea/workflows/server-deploy.yml"
jobs:
verify:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: new_pet_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=10
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.2"
extensions: bcmath, curl, dom, fileinfo, intl, mbstring, pdo_mysql, redis, xml, zip
coverage: none
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install server dependencies
working-directory: server
run: |
composer validate --no-check-publish
composer install --no-interaction --prefer-dist
npm ci
- name: Prepare the test environment
working-directory: server
run: |
cp .env.test .env.testing
php artisan key:generate --env=testing
- name: Run HTTP API tests
working-directory: server
run: php artisan test
- name: Build server assets
working-directory: server
run: npm run build
- uses: actions/upload-artifact@v4
with:
name: server-build
path: server/public/build
if-no-files-found: error
deploy:
needs: verify
runs-on: production
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: server-build
path: server/public/build
- uses: https://gitea.neatcn.com/pets/deployment-toolkit/actions/laravel-release@v1
with:
source-dir: server
deploy-root: ${{ vars.SERVER_DEPLOY_ROOT }}
container-deploy-root: ${{ vars.SERVER_CONTAINER_DEPLOY_ROOT }}
php-container: ${{ vars.SERVER_PHP_CONTAINER }}
healthcheck-url: ${{ vars.SERVER_HEALTHCHECK_URL }}
backup-command: ${{ secrets.SERVER_BACKUP_COMMAND }}
release-id: ${{ gitea.sha }}
+60
View File
@@ -0,0 +1,60 @@
name: Publish Mini Program Preview
on:
push:
branches:
- main
paths:
- "miniapp/**"
- ".nvmrc"
- ".gitea/workflows/miniapp-preview.yml"
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: miniapp/package-lock.json
- name: Install dependencies
working-directory: miniapp
run: npm ci
- name: Run tests
working-directory: miniapp
run: npm test -- --runInBand
- name: Configure production API
working-directory: miniapp
env:
TARO_APP_API_URL: ${{ vars.MINIAPP_API_URL }}
run: |
test -n "$TARO_APP_API_URL"
printf 'TARO_APP_API_URL=%s\n' "$TARO_APP_API_URL" > .env.production.local
- name: Build WeChat Mini Program
working-directory: miniapp
run: npm run build:weapp
- name: Materialize the upload key
env:
WECHAT_CI_PRIVATE_KEY: ${{ secrets.WECHAT_CI_PRIVATE_KEY }}
run: |
test -n "$WECHAT_CI_PRIVATE_KEY"
umask 077
printf '%s' "$WECHAT_CI_PRIVATE_KEY" > "$RUNNER_TEMP/wechat-ci.key"
- uses: https://gitea.neatcn.com/pets/deployment-toolkit/actions/wechat-preview@v1
with:
project-path: miniapp
appid: ${{ vars.MINIAPP_APPID }}
robot: ${{ vars.MINIAPP_ROBOT }}
private-key-path: ${{ runner.temp }}/wechat-ci.key
qrcode-output: ${{ runner.temp }}/miniapp-preview.png
version-label: ${{ gitea.sha }}
- uses: actions/upload-artifact@v4
with:
name: miniapp-preview-${{ gitea.sha }}
path: ${{ runner.temp }}/miniapp-preview.png
if-no-files-found: error
- name: Remove temporary credentials
if: always()
run: rm -f "$RUNNER_TEMP/wechat-ci.key"