Shared GitHub Actions for deploying the FIRST Global apps (first-global-api and first-global-results) to the PM2 host.
Two composite actions:
| Action | Purpose |
|---|---|
ci-gate |
Answers "have all of this repo's CI workflows gone green at this commit?" |
deploy |
Ships a built tree to the VPS as a new release and reloads PM2. |
This repository is public so that first-global-results — itself public — can consume
it. GitHub only shares actions from a private repository with other private
repositories, so a private version here would need a PAT-checkout workaround in every
workflow. Nothing secret lives in this repo: hosts, keys, and paths are all passed in
as inputs.
Each app has a deploy root holding immutable releases and the files that exist only on the server:
/home/webserver/deploy/web/
releases/
20260817T203344-a1b2c3d4e5f6/ <- unpacked tarball + npm ci --omit=dev
20260817T191002-9f8e7d6c5b4a/
shared/
.env <- symlinked into every release
/home/webserver/web -> deploy/web/releases/20260817T203344-a1b2c3d4e5f6
/home/webserver/web is the path PM2 already knows. It becomes a symlink, and deploying
means pointing it at a new release and reloading. PM2 stores pm_cwd and
pm_exec_path verbatim and re-resolves them when it spawns, so keeping that path string
identical is what lets the processes survive without being re-registered.
A deploy is: unpack → symlink shared files → npm ci --omit=dev → atomically swap the
symlink → pm2 reload → health check → prune to the newest 5. If the health check
fails, the symlink goes back to the previous release and PM2 is reloaded again.
| App | PM2 process | Path PM2 points at | Deploy root |
|---|---|---|---|
| api (prod) | FGC-API |
/home/webserver/api |
/home/webserver/deploy/api |
| api (stage) | FGC API Stage |
/home/webserver/api_stage |
/home/webserver/deploy/api_stage |
| web (prod) | FGC-WEB |
/home/webserver/web |
/home/webserver/deploy/web |
| web (stage) | FGC WEB Stage |
/home/webserver/web_stage |
/home/webserver/deploy/web_stage |
Run once per app, as webserver, before the first deploy. deploy refuses to run
against a path that is still a real directory, so a half-done migration fails loudly
rather than leaving PM2 on stale code.
Start with api_stage — it is the cheapest place to discover that PM2 disagrees.
APP=api_stage # then web_stage, then api, then web
PM2_NAME='FGC API Stage' # then 'FGC WEB Stage', 'FGC-API', 'FGC-WEB'
SRC="/home/webserver/$APP"
ROOT="/home/webserver/deploy/$APP"
# 1. Record what PM2 currently believes, so a regression is obvious.
pm2 describe "$PM2_NAME" | tee "$HOME/pm2-$APP.before.txt"
# 2. Stage the new layout and preserve the server-only .env.
mkdir -p "$ROOT/releases" "$ROOT/shared"
cp "$SRC/.env" "$ROOT/shared/.env"
# 3. Move the existing checkout in as the first release, then put a symlink back
# at the path PM2 knows.
mv "$SRC" "$ROOT/releases/00000000000000-legacy"
ln -s "$ROOT/releases/00000000000000-legacy" "$SRC"
# 4. The legacy release keeps its own .env file; replace it with the shared link
# so it matches every release that follows.
rm -f "$ROOT/releases/00000000000000-legacy/.env"
ln -s "$ROOT/shared/.env" "$ROOT/releases/00000000000000-legacy/.env"
# 5. Confirm nothing moved out from under PM2.
pm2 reload "$PM2_NAME" --update-env
pm2 describe "$PM2_NAME"Compare against pm2-$APP.before.txt: exec cwd and script path should read the same
as before, and the process should be online. Verify the app actually answers before
moving to the next one.
After all four, persist the process list:
pm2 saveGenerate a key pair for CI (no passphrase — Actions cannot answer a prompt):
ssh-keygen -t ed25519 -C 'github-actions-deploy' -f ~/fgc-deploy -N ''
cat ~/fgc-deploy.pub >> /home/webserver/.ssh/authorized_keysThe private half goes into the DEPLOY_SSH_KEY secret in both repos. Capture the host
key for DEPLOY_KNOWN_HOSTS from a trusted machine:
ssh-keyscan -H <vps-host> 2>/dev/nullHost key checking is deliberately left on, so this secret is required. GitHub-hosted
runners have dynamic egress addresses, so port 22 has to be broadly reachable — pair
this with PasswordAuthentication no and fail2ban.
jobs:
gate:
runs-on: ubuntu-latest
outputs:
ok: ${{ steps.gate.outputs.ok }}
steps:
- id: gate
uses: the-orange-alliance/fgc-actions/ci-gate@v1
with:
workflows: |
Build
Test
sha: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.GITHUB_TOKEN }}workflows takes the name: of each workflow, not its filename. A workflow still
running yields ok=false; its own completion re-fires the caller, so the last workflow
to finish is the one that proceeds. Nothing deploys if any of them failed.
- uses: the-orange-alliance/fgc-actions/deploy@v1
with:
source-dir: release
host: ${{ secrets.DEPLOY_HOST }}
ssh-user: ${{ vars.DEPLOY_USER }}
ssh-key: ${{ secrets.DEPLOY_SSH_KEY }}
known-hosts: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
deploy-root: ${{ vars.DEPLOY_ROOT }}
app-path: ${{ vars.APP_PATH }}
pm2-name: ${{ vars.PM2_NAME }}
health-url: ${{ vars.HEALTH_URL }}Everything under source-dir is shipped. Shape it in the build job — usually by
downloading an artifact into a directory — rather than filtering here.
| Input | Default | Notes |
|---|---|---|
source-dir |
. |
Contents become the release. |
host |
— | VPS host or IP. |
ssh-user |
webserver |
Must own the PM2 daemon. |
ssh-port |
22 |
|
ssh-key |
— | Unencrypted private key. |
known-hosts |
— | ssh-keyscan -H output. Required. |
deploy-root |
— | Holds releases/ and shared/. |
app-path |
— | The symlink PM2 points at. Must already be a symlink. |
pm2-name |
— | May contain spaces. |
release-id |
timestamp + short SHA | Re-deploying the same id replaces that release. |
npm-install |
true |
Runs npm ci --omit=dev in the release. |
shared-files |
.env |
Newline-separated; symlinked from shared/. |
keep-releases |
5 |
The live release is never pruned. |
health-url |
none | Polled after reload; failure triggers rollback. |
health-retries |
10 |
|
health-delay |
3 |
Seconds between attempts. |
node, npm, and pm2 must be on PATH in ~/.bashrc for the SSH user, which is
what sshd sources when running a remote command. The remote script checks this up front
and refuses to start otherwise.
This is deliberately not a login shell. A host can easily carry two node installs — one
on ~/.bashrc (where pm2 is, and what the PM2 daemon was started from) and a different
one on ~/.profile. Reading the login shell would install dependencies against one node
and run the app under another, and may not find pm2 at all.
Releases are kept, so rolling back is repointing the symlink:
ROOT=/home/webserver/deploy/web
ls -1t "$ROOT/releases" # newest first
ln -sfn "$ROOT/releases/<previous>" "$ROOT/.rollback"
mv -Tf "$ROOT/.rollback" /home/webserver/web
pm2 reload 'FGC-WEB' --update-envThe next successful deploy supersedes it. Note that a rolled-back release still counts
toward keep-releases, and the live one is never pruned.
Consumers pin @v1. The v1 tag moves forward for backwards-compatible changes; a
breaking change to inputs gets a v2.