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 = {};