From 570586bae35ae10a32ee6b1cb9c7796cf2b6cc65 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sun, 4 Oct 2026 03:49:16 +0400 Subject: [PATCH] Deploy apps.pezkuwichain.io from main behind an approval push-main.yml now builds build:www on every push to main and, once the production environment is approved, copies it to the web host and switches to it. The host keeps releases side by side and serves a 'current' symlink, so going live is one rename and rollback is a dispatch with an earlier sha. .github/deploy/release.sh checks the switch through nginx for the public name and restores the previous release if the new one is not served. Ships env-config.js, which index.html loads: on a static host the request fell through to index.html and the browser ran HTML as a script. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/deploy/release.sh | 81 ++++++++++++++++ .github/workflows/push-main.yml | 143 +++++++++++------------------ .gitignore | 1 - packages/apps/public/env-config.js | 6 ++ 4 files changed, 141 insertions(+), 90 deletions(-) create mode 100755 .github/deploy/release.sh create mode 100644 packages/apps/public/env-config.js diff --git a/.github/deploy/release.sh b/.github/deploy/release.sh new file mode 100755 index 0000000..6e24f1b --- /dev/null +++ b/.github/deploy/release.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Runs on the web host as the apps-deploy user, fed over ssh by push-main.yml. +# +# release.sh deploy unpack incoming/.tgz and make it live +# release.sh rollback make an already unpacked release live again +# +# nginx serves /var/www/subdomains/apps, a symlink to $BASE/current, which in +# turn points at releases/. Going live is one rename of that link, so a +# reader never sees a half-copied tree, and the previous release stays on +# disk for an instant rollback. +set -euo pipefail + +BASE=${APPS_DEPLOY_BASE:-/var/www/apps-deploy} +SITE=apps.pezkuwichain.io +KEEP=5 + +mode=${1:?mode} +sha=${2:?sha} +[[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "not a commit sha: $sha"; exit 1; } + +rel=$BASE/releases/$sha + +switch_to () { + ln -sfn "releases/$1" "$BASE/current.new" + mv -T "$BASE/current.new" "$BASE/current" +} + +# The answer has to come through nginx for the public name, not from the +# disk: that is the path a visitor takes. +served_sha () { + curl -sf --max-time 10 --resolve "$SITE:443:127.0.0.1" "https://$SITE/.deploy-sha" +} + +check_live () { + local want=$1 entry + [ "$(served_sha)" = "$want" ] || return 1 + # The page must load: its entry script has to be served too. + entry=$(curl -sf --max-time 10 --resolve "$SITE:443:127.0.0.1" "https://$SITE/" \ + | grep -o 'src="[^"]*main\.[0-9a-f]*\.js"' | head -1 | cut -d'"' -f2) + [ -n "$entry" ] || return 1 + curl -sf --max-time 10 -o /dev/null --resolve "$SITE:443:127.0.0.1" "https://$SITE/${entry#/}" +} + +prev=$(readlink "$BASE/current" || true) + +case $mode in + deploy) + tgz=$BASE/incoming/$sha.tgz + rm -rf "$rel.tmp" + mkdir -p "$rel.tmp" + tar -xzf "$tgz" -C "$rel.tmp" + [ -f "$rel.tmp/index.html" ] || { echo "no index.html in the bundle"; exit 1; } + [ "$(cat "$rel.tmp/.deploy-sha")" = "$sha" ] || { echo ".deploy-sha does not match $sha"; exit 1; } + rm -rf "$rel" + mv "$rel.tmp" "$rel" + rm -f "$tgz" + ;; + rollback) + [ -d "$rel" ] || { echo "no unpacked release $sha"; ls "$BASE/releases"; exit 1; } + ;; + *) + echo "unknown mode: $mode"; exit 1 ;; +esac + +switch_to "$sha" + +if ! check_live "$sha"; then + echo "$SITE does not serve $sha after the switch" + if [ -n "$prev" ]; then + switch_to "${prev#releases/}" + check_live "${prev#releases/}" && echo "restored ${prev#releases/}" || echo "RESTORE FAILED: ${prev#releases/} is not served either" + fi + exit 1 +fi +echo "live: $sha (was ${prev#releases/})" + +# Keep the newest releases, and always the one just replaced. +cd "$BASE/releases" +ls -1t | tail -n +$((KEEP + 1)) | while read -r old; do + [ "$old" = "$sha" ] || [ "releases/$old" = "$prev" ] || rm -rf -- "$old" +done diff --git a/.github/workflows/push-main.yml b/.github/workflows/push-main.yml index ec9da3e..52bea0a 100644 --- a/.github/workflows/push-main.yml +++ b/.github/workflows/push-main.yml @@ -1,116 +1,81 @@ name: Main on: - workflow_dispatch: push: branches: - main + workflow_dispatch: + inputs: + rollback_to: + description: 'Make an earlier release live again (full commit sha); empty = deploy this commit' + required: false + default: '' + +# Deploys wait for each other; a newer push queues behind a running one. +concurrency: + group: deploy-apps + cancel-in-progress: false permissions: - contents: write - pages: write + contents: read jobs: - # publish to gh-pages (& IPFS when a release is detected) - www: - if: "! startsWith(github.event.head_commit.message, '[CI Skip]')" - strategy: - matrix: - step: ['build:release:www'] + build: + if: github.event.inputs.rollback_to == '' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - persist-credentials: false - - name: Configure Git Credentials - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" - uses: actions/setup-node@v4 with: node-version: 'lts/*' - - name: Set Execute Permissions - run: chmod +x ./scripts/* - - name: ${{ matrix.step }} + - name: build:www env: - CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} - CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} - CRUST_SEEDS: ${{ secrets.CRUST_SEEDS }} - GH_PAGES_SRC: packages/apps/build - GH_PAT: x-access-token:${{ secrets.GITHUB_TOKEN }} - GH_RELEASE_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - PINATA_API_KEY: ${{ secrets.PINATA_API_KEY }} - PINATA_SECRET_KEY: ${{ secrets.PINATA_SECRET_KEY }} + NODE_OPTIONS: --max-old-space-size=6144 run: | yarn install --immutable - yarn ${{ matrix.step }} + yarn build:www + echo "$GITHUB_SHA" > packages/apps/build/.deploy-sha + tar -C packages/apps/build -czf apps-www.tgz . + - uses: actions/upload-artifact@v4 + with: + name: apps-www + path: apps-www.tgz + retention-days: 7 - # only run on "CI skip", i.e. when the actual version has been bumped to release/stable - docker: - if: "startsWith(github.event.head_commit.message, '[CI Skip] release/stable')" + # The only job that names the environment, so a run asks for approval once, + # and the deploy secrets (environment-scoped) exist only after it is given. + deploy: + needs: build + if: always() && (needs.build.result == 'success' || (needs.build.result == 'skipped' && github.event.inputs.rollback_to != '')) runs-on: ubuntu-latest + environment: production + env: + HOST: ${{ secrets.APPS_DEPLOY_HOST }} steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 with: - node-version: 'lts/*' - - name: Set Execute Permissions - run: chmod +x ./docker/* - - name: docker + sparse-checkout: .github/deploy + - name: ssh setup env: - DOCKER_PASS: ${{ secrets.DOCKER_PASS }} + KEY: ${{ secrets.APPS_DEPLOY_SSH_KEY }} + KNOWN_HOSTS: ${{ secrets.APPS_DEPLOY_KNOWN_HOSTS }} run: | - ./docker/build.sh - - # only run on "CI skip", i.e. when the actual version has been bumped to release/stable - electron: - if: "startsWith(github.event.head_commit.message, '[CI Skip] release/stable')" - strategy: - matrix: - # Removed `macos-latest` since notarization errors. - # ref: https://github.com/pezkuwichain/pezkuwi-apps/issues/10486 - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 + install -m 700 -d ~/.ssh + printf '%s\n' "$KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + - uses: actions/download-artifact@v4 + if: github.event.inputs.rollback_to == '' with: - token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/setup-node@v4 - with: - node-version: 'lts/*' - # Removed `macos-latest` since notarization errors. - # ref: https://github.com/pezkuwichain/pezkuwi-apps/issues/10486 - # - name: Prepare for app notarization (macOS) - # if: startsWith(matrix.os, 'macos') - # # Import Apple API key for app notarization on macOS - # run: | - # mkdir -p ~/private_keys/ - # echo '${{ secrets.API_KEY }}' > ~/private_keys/AuthKey_${{ secrets.API_KEY_ID }}.p8 - - name: Set Execute Permissions - run: chmod +x ./scripts/* - - name: Build/release Electron app - uses: samuelmeuli/action-electron-builder@v1 - with: - # Base64-encoded code signing certificate for macOS - mac_certs: ${{ secrets.MAC_CERTS }} - # Password for decrypting `mac_certs` - mac_certs_password: ${{ secrets.MAC_CERTS_PASSWORD }} - # Base64-encoded code signing certificate for Windows - windows_certs: ${{ secrets.WINDOWS_CERTS }} - # Password for decrypting `windows_certs` - windows_certs_password: ${{ secrets.WINDOWS_CERTS_PASSWORD }} - # GitHub token, automatically provided to the action - # (No need to define this secret in the repo settings) - github_token: ${{ secrets.GITHUB_TOKEN }} - args: '--project packages/apps-electron' - build_script_name: build:release:electron - # If the commit is tagged with a version (e.g. "v1.0.0"), - # release the app after building - release: ${{ startsWith(github.ref, 'refs/tags/v') }} + name: apps-www + - name: upload + if: github.event.inputs.rollback_to == '' + run: scp -o BatchMode=yes apps-www.tgz "apps-deploy@$HOST:/var/www/apps-deploy/incoming/$GITHUB_SHA.tgz" + - name: go live env: - # macOS notarization API key - API_KEY_ID: ${{ secrets.API_KEY_ID }} - API_KEY_ISSUER_ID: ${{ secrets.API_KEY_ISSUER_ID }} + ROLLBACK_TO: ${{ github.event.inputs.rollback_to }} + run: | + if [ -n "$ROLLBACK_TO" ]; then set -- rollback "$ROLLBACK_TO"; else set -- deploy "$GITHUB_SHA"; fi + # ssh hands its arguments to the remote shell as one string: only a + # bare sha may get there. + [[ $2 =~ ^[0-9a-f]{40}$ ]] || { echo "not a full commit sha: $2"; exit 1; } + ssh -o BatchMode=yes "apps-deploy@$HOST" bash -s -- "$@" < .github/deploy/release.sh diff --git a/.gitignore b/.gitignore index 7aa24c3..34c7159 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,4 @@ yarn-debug.log* yarn-error.log* .idea/ *.log -env-config.js NOTES.md diff --git a/packages/apps/public/env-config.js b/packages/apps/public/env-config.js new file mode 100644 index 0000000..f5848e7 --- /dev/null +++ b/packages/apps/public/env-config.js @@ -0,0 +1,6 @@ +// Copyright 2017-2026 @pezkuwi/apps authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Runtime settings. Empty on a static host; the Docker image rewrites this +// file from its environment on start (docker/env.sh). +window.process_env = {};