From ec0f2aab5a6ed8c4c0cdb86b6f3420452ecc5e64 Mon Sep 17 00:00:00 2001 From: gouki Date: Wed, 15 Jul 2026 21:49:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=8F=AF=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E9=83=A8=E7=BD=B2=E5=B7=A5=E4=BD=9C=E6=B5=81=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- CHANGELOG.md | 8 ++ README.md | 30 +++++ actions/laravel-release/action.yml | 45 +++++++ actions/laravel-release/deploy-release.sh | 151 ++++++++++++++++++++++ actions/wechat-preview/action.yml | 41 ++++++ actions/wechat-preview/preview.cjs | 70 ++++++++++ examples/secrets.md | 38 ++++++ workflows/laravel-onepanel.yml | 80 ++++++++++++ workflows/taro-wechat-preview.yml | 60 +++++++++ 9 files changed, 523 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 actions/laravel-release/action.yml create mode 100755 actions/laravel-release/deploy-release.sh create mode 100644 actions/wechat-preview/action.yml create mode 100755 actions/wechat-preview/preview.cjs create mode 100644 examples/secrets.md create mode 100644 workflows/laravel-onepanel.yml create mode 100644 workflows/taro-wechat-preview.yml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4488225 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e42922c --- /dev/null +++ b/README.md @@ -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. diff --git a/actions/laravel-release/action.yml b/actions/laravel-release/action.yml new file mode 100644 index 0000000..5e5b2bf --- /dev/null +++ b/actions/laravel-release/action.yml @@ -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"' diff --git a/actions/laravel-release/deploy-release.sh b/actions/laravel-release/deploy-release.sh new file mode 100755 index 0000000..0869b11 --- /dev/null +++ b/actions/laravel-release/deploy-release.sh @@ -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" diff --git a/actions/wechat-preview/action.yml b/actions/wechat-preview/action.yml new file mode 100644 index 0000000..d02d537 --- /dev/null +++ b/actions/wechat-preview/action.yml @@ -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" diff --git a/actions/wechat-preview/preview.cjs b/actions/wechat-preview/preview.cjs new file mode 100755 index 0000000..36e2482 --- /dev/null +++ b/actions/wechat-preview/preview.cjs @@ -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; +}); diff --git a/examples/secrets.md b/examples/secrets.md new file mode 100644 index 0000000..348492b --- /dev/null +++ b/examples/secrets.md @@ -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= +``` + +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. diff --git a/workflows/laravel-onepanel.yml b/workflows/laravel-onepanel.yml new file mode 100644 index 0000000..0b4f674 --- /dev/null +++ b/workflows/laravel-onepanel.yml @@ -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 }} diff --git a/workflows/taro-wechat-preview.yml b/workflows/taro-wechat-preview.yml new file mode 100644 index 0000000..ed21f4e --- /dev/null +++ b/workflows/taro-wechat-preview.yml @@ -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"