mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
chore(ci): prune remaining superseded/redundant GitHub Actions workflows (#3935)
This commit is contained in:
@@ -1,346 +0,0 @@
|
||||
---
|
||||
# Security note: this workflow intentionally uses `pull_request_target` so the
|
||||
# `reward user` label can fire on PRs from forks (the plain `pull_request`
|
||||
# trigger cannot see labels applied to fork PRs and runs under a read-only
|
||||
# GITHUB_TOKEN that cannot post comments / add labels).
|
||||
#
|
||||
# The job is bounded:
|
||||
# - it never checks out fork code (no `actions/checkout` with `pull_request.head.ref`)
|
||||
# - it never executes arbitrary scripts from the PR
|
||||
# - the only side effects are: posting a templated comment and adding a label
|
||||
# - permissions are minimal (contents:read, issues:write, pull-requests:read)
|
||||
# - all PR/issue user metadata interpolated into search queries or comment
|
||||
# bodies is treated as untrusted and normalized through safeLogin/safeUrl
|
||||
# before use (see the inline script below).
|
||||
#
|
||||
# Do NOT broaden permissions, add a fork checkout, or remove the input
|
||||
# normalization without a security review.
|
||||
|
||||
name: Contributor Rewards
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed, labeled]
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: contributor-rewards-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
reward:
|
||||
name: Invite eligible contributor
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'closed' &&
|
||||
github.event.pull_request.merged == true) ||
|
||||
(github.event.action == 'labeled' &&
|
||||
github.event.label.name == 'reward user')
|
||||
steps:
|
||||
- name: Post reward invite when eligible
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
CONTRIBUTOR_REWARD_TRIGGER_LABEL: reward user
|
||||
CONTRIBUTOR_REWARD_SENT_LABEL: reward sent
|
||||
CONTRIBUTOR_REWARD_DISCORD_URL: ${{ vars.CONTRIBUTOR_REWARD_DISCORD_URL }}
|
||||
CONTRIBUTOR_REWARD_MERCH_URL: ${{ vars.CONTRIBUTOR_REWARD_MERCH_URL }}
|
||||
CONTRIBUTOR_REWARD_MESSAGE: ${{ vars.CONTRIBUTOR_REWARD_MESSAGE }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const triggerLabel = (process.env.CONTRIBUTOR_REWARD_TRIGGER_LABEL || 'reward user').trim();
|
||||
const sentLabel = (process.env.CONTRIBUTOR_REWARD_SENT_LABEL || 'reward sent').trim();
|
||||
const discordUrl =
|
||||
(process.env.CONTRIBUTOR_REWARD_DISCORD_URL || 'https://discord.tinyhumans.ai/').trim();
|
||||
const merchUrl = (process.env.CONTRIBUTOR_REWARD_MERCH_URL || '').trim();
|
||||
const customMessage = (process.env.CONTRIBUTOR_REWARD_MESSAGE || '').trim();
|
||||
|
||||
// Treat all PR/issue metadata as untrusted display-only data — this
|
||||
// workflow runs under `pull_request_target` so anything coming from
|
||||
// a fork (login, html_url) crosses a trust boundary. Normalize
|
||||
// before interpolating into search queries or comment bodies.
|
||||
// GitHub usernames are 1-39 chars, alphanumeric + hyphen.
|
||||
const safeLogin = (s) => String(s || '').replace(/[^A-Za-z0-9-]/g, '').slice(0, 39);
|
||||
// Only allow github.com / *.github.com (covers GitHub Enterprise).
|
||||
// Strip query/fragment to defeat comment-body injection via crafted
|
||||
// URLs. Return '' if the URL is unparseable or off-host.
|
||||
const safeUrl = (s) => {
|
||||
try {
|
||||
const u = new URL(String(s || ''));
|
||||
const okHost = u.hostname === 'github.com' || u.hostname.endsWith('.github.com');
|
||||
return okHost ? `${u.origin}${u.pathname}` : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const normalize = value => String(value || '').trim().toLowerCase();
|
||||
const triggerLabelKey = normalize(triggerLabel);
|
||||
|
||||
function markerFor(login) {
|
||||
return `<!-- openhuman-contributor-reward:user=${normalize(login)} -->`;
|
||||
}
|
||||
|
||||
async function ensureLabel(name, color, description) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color, description });
|
||||
core.info(`Created label "${name}".`);
|
||||
} catch (error) {
|
||||
if (error.status !== 422) throw error;
|
||||
core.info(`Label "${name}" already exists.`);
|
||||
}
|
||||
}
|
||||
|
||||
function labeledWithTrigger() {
|
||||
return normalize(context.payload.label?.name) === triggerLabelKey;
|
||||
}
|
||||
|
||||
function actorIsBot(target) {
|
||||
return target.userType === 'Bot' || /\[bot\]$/i.test(target.login);
|
||||
}
|
||||
|
||||
function targetFromEvent() {
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
return { bootstrapOnly: true };
|
||||
}
|
||||
|
||||
if (context.eventName === 'pull_request_target') {
|
||||
const pr = context.payload.pull_request;
|
||||
if (!pr) return null;
|
||||
|
||||
if (context.payload.action === 'closed') {
|
||||
if (!pr.merged) {
|
||||
core.info(`PR #${pr.number} was closed without merge; skipping reward flow.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const login = safeLogin(pr.user?.login);
|
||||
if (!login) {
|
||||
core.info(`PR #${pr.number} has an unrecognized author login shape; skipping.`);
|
||||
return null;
|
||||
}
|
||||
const htmlUrl = safeUrl(pr.html_url);
|
||||
|
||||
return {
|
||||
kind: 'pull request',
|
||||
number: pr.number,
|
||||
htmlUrl,
|
||||
login,
|
||||
userType: pr.user?.type,
|
||||
reason: 'first merged pull request',
|
||||
manualOverride: false,
|
||||
requireFirstMergedPr: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (context.payload.action === 'labeled') {
|
||||
if (!labeledWithTrigger()) {
|
||||
core.info(`Label "${context.payload.label?.name}" is not "${triggerLabel}"; skipping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const login = safeLogin(pr.user?.login);
|
||||
if (!login) {
|
||||
core.info(`PR #${pr.number} has an unrecognized author login shape; skipping.`);
|
||||
return null;
|
||||
}
|
||||
const htmlUrl = safeUrl(pr.html_url);
|
||||
|
||||
return {
|
||||
kind: 'pull request',
|
||||
number: pr.number,
|
||||
htmlUrl,
|
||||
login,
|
||||
userType: pr.user?.type,
|
||||
reason: `maintainer-applied "${triggerLabel}" label on a pull request`,
|
||||
manualOverride: true,
|
||||
requireFirstMergedPr: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (context.eventName === 'issues') {
|
||||
const issue = context.payload.issue;
|
||||
if (!issue) return null;
|
||||
|
||||
if (issue.pull_request) {
|
||||
core.info(`Issue event is for PR #${issue.number}; pull_request_target handles PR labels.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!labeledWithTrigger()) {
|
||||
core.info(`Label "${context.payload.label?.name}" is not "${triggerLabel}"; skipping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const login = safeLogin(issue.user?.login);
|
||||
if (!login) {
|
||||
core.info(`Issue #${issue.number} has an unrecognized author login shape; skipping.`);
|
||||
return null;
|
||||
}
|
||||
const htmlUrl = safeUrl(issue.html_url);
|
||||
|
||||
return {
|
||||
kind: 'issue',
|
||||
number: issue.number,
|
||||
htmlUrl,
|
||||
login,
|
||||
userType: issue.user?.type,
|
||||
reason: `maintainer-applied "${triggerLabel}" label on an issue`,
|
||||
manualOverride: true,
|
||||
requireFirstMergedPr: false,
|
||||
};
|
||||
}
|
||||
|
||||
core.info(`Unsupported event "${context.eventName}"; skipping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function isFirstMergedPullRequest(login, currentNumber) {
|
||||
const query = `repo:${owner}/${repo} is:pr is:merged author:${login}`;
|
||||
const response = await github.rest.search.issuesAndPullRequests({
|
||||
q: query,
|
||||
per_page: 100,
|
||||
});
|
||||
const previous = response.data.items.filter(item => item.number !== currentNumber);
|
||||
core.info(
|
||||
`Found ${previous.length} earlier merged PR(s) for ${login} while evaluating #${currentNumber}.`
|
||||
);
|
||||
return previous.length === 0;
|
||||
}
|
||||
|
||||
async function targetAlreadyRewarded(target) {
|
||||
const marker = markerFor(target.login);
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: target.number,
|
||||
per_page: 100,
|
||||
});
|
||||
return comments.some(comment => comment.body?.includes(marker));
|
||||
}
|
||||
|
||||
async function contributorAlreadyRewardedElsewhere(target) {
|
||||
const markerNeedle = `openhuman-contributor-reward:user=${normalize(target.login)}`;
|
||||
const query = `repo:${owner}/${repo} "${markerNeedle}" in:comments`;
|
||||
const response = await github.rest.search.issuesAndPullRequests({
|
||||
q: query,
|
||||
per_page: 10,
|
||||
});
|
||||
return response.data.items.some(item => item.number !== target.number);
|
||||
}
|
||||
|
||||
function renderCustomMessage(target) {
|
||||
const replacements = {
|
||||
'{user}': `@${target.login}`,
|
||||
'{login}': target.login,
|
||||
'{discord_url}': discordUrl,
|
||||
'{merch_url}': merchUrl,
|
||||
'{reason}': target.reason,
|
||||
'{target_url}': target.htmlUrl,
|
||||
};
|
||||
|
||||
let body = customMessage;
|
||||
for (const [token, value] of Object.entries(replacements)) {
|
||||
body = body.split(token).join(value);
|
||||
}
|
||||
return `${markerFor(target.login)}\n${body}`;
|
||||
}
|
||||
|
||||
function renderDefaultMessage(target) {
|
||||
const merchLine = merchUrl
|
||||
? `3. Claim merch: ${merchUrl}`
|
||||
: '3. For merch, ask a maintainer in Discord. Keep shipping details out of GitHub comments.';
|
||||
|
||||
return [
|
||||
markerFor(target.login),
|
||||
`Thanks @${target.login} for contributing to OpenHuman.`,
|
||||
'',
|
||||
'You are eligible for the contributor reward flow:',
|
||||
'',
|
||||
`1. Join the OpenHuman Discord: ${discordUrl}`,
|
||||
`2. Share this ${target.kind} link so maintainers can verify your GitHub handle: ${target.htmlUrl}`,
|
||||
merchLine,
|
||||
'',
|
||||
`*Reason: ${target.reason}. Automatic rewards are idempotent per GitHub user; maintainer-applied "${triggerLabel}" labels can intentionally start the flow for special cases.*`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const target = targetFromEvent();
|
||||
|
||||
await ensureLabel(triggerLabel, 'fbca04', 'Maintainer-triggered contributor reward invite');
|
||||
await ensureLabel(sentLabel, '0e8a16', 'Contributor reward invite has been posted');
|
||||
|
||||
if (!target) {
|
||||
await core.summary.addHeading('Contributor Rewards').addRaw('No eligible target for this event.').write();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.bootstrapOnly) {
|
||||
await core.summary
|
||||
.addHeading('Contributor Rewards')
|
||||
.addRaw(`Labels "${triggerLabel}" and "${sentLabel}" are ready.`)
|
||||
.write();
|
||||
return;
|
||||
}
|
||||
|
||||
if (actorIsBot(target)) {
|
||||
core.info(`Skipping bot account ${target.login}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.requireFirstMergedPr) {
|
||||
const firstMergedPr = await isFirstMergedPullRequest(target.login, target.number);
|
||||
if (!firstMergedPr) {
|
||||
core.info(`${target.login} already has a merged PR; skipping automatic reward.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (await targetAlreadyRewarded(target)) {
|
||||
core.info(`#${target.number} already has a reward marker for ${target.login}; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.manualOverride && (await contributorAlreadyRewardedElsewhere(target))) {
|
||||
core.info(`${target.login} already has a reward marker elsewhere; skipping automatic reward.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = customMessage ? renderCustomMessage(target) : renderDefaultMessage(target);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: target.number,
|
||||
body,
|
||||
});
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: target.number,
|
||||
labels: [sentLabel],
|
||||
});
|
||||
|
||||
core.info(`Posted contributor reward invite for ${target.login} on #${target.number}.`);
|
||||
await core.summary
|
||||
.addHeading('Contributor Rewards')
|
||||
.addRaw(`Posted reward invite for @${target.login} on ${target.htmlUrl}.`)
|
||||
.write();
|
||||
@@ -1,688 +0,0 @@
|
||||
---
|
||||
# Deployment workflow for OpenHuman
|
||||
# Handles deployment of both the Rust core (openhuman-core CLI) and the React + Tauri desktop app.
|
||||
#
|
||||
# This workflow:
|
||||
# - Validates the environment and build prerequisites
|
||||
# - Runs pre-deployment checks (TypeScript compilation, linting)
|
||||
# - Resolves platform filter matrix so build jobs only run for selected platforms
|
||||
# - Compiles the Rust core + CLI binaries for all requested platforms
|
||||
# - Builds the React frontend + Tauri desktop bundles (macOS, Linux, Windows)
|
||||
# - Stages artifacts for distribution
|
||||
# - Signs and notarizes macOS bundles (production only)
|
||||
# - Publishes release assets and Docker images
|
||||
#
|
||||
# Triggers:
|
||||
# - Manual dispatch for on-demand deployments (specify environment: staging/production)
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# FIX LOG (vs. previous revision)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# [F-1] CRITICAL — matrix context unavailable in job-level `if`.
|
||||
# `matrix.*` is only resolved inside step-level expressions; a job-level
|
||||
# `if: contains(..., matrix.platform_filter)` always evaluates against an
|
||||
# empty string and silently skips every matrix leg.
|
||||
# Fix: added a `resolve-matrix` job that converts the comma-separated
|
||||
# input into a JSON include-list; `build-rust-core` and `build-desktop`
|
||||
# now consume `needs.resolve-matrix.outputs.*` which IS available at
|
||||
# job-level. Platform filtering is therefore correct and deterministic.
|
||||
#
|
||||
# [F-2] `pre-checks` skipped when `skip_tests=true` stalled all build jobs.
|
||||
# Downstream jobs listed `pre-checks` in `needs`, so when it was
|
||||
# conditionally skipped GitHub marked dependents as "not run".
|
||||
# Fix: build jobs now use
|
||||
# `if: needs.pre-checks.result == 'success' ||
|
||||
# needs.pre-checks.result == 'skipped'`
|
||||
# so they proceed whether tests ran or were intentionally bypassed.
|
||||
#
|
||||
# [F-3] `publish-release` condition used `&&` but `build-docker` can be
|
||||
# 'skipped' when docker is excluded from `deploy_platforms`.
|
||||
# Fix: condition now explicitly allows 'skipped' for build-docker:
|
||||
# `build-docker.result != 'failure'`
|
||||
# Analogous guard added for build-rust-core and build-desktop.
|
||||
#
|
||||
# [F-4] Docker `type=semver` tag pattern requires a pushed git tag; on a
|
||||
# `workflow_dispatch` trigger it produces an empty/no-op tag.
|
||||
# Fix: replaced with explicit `type=raw,value=${{ needs.validate.outputs.version }}`
|
||||
# and kept the SHA and branch tags which work on any trigger.
|
||||
#
|
||||
# [F-5] `actions/checkout@v5` does not exist (latest stable is v4).
|
||||
# `softprops/action-gh-release@v3.0.0` does not exist (latest is v2).
|
||||
# Fix: pinned all actions to their current latest major version.
|
||||
#
|
||||
# [F-6] Rust toolchain version was duplicated — `env.RUST_VERSION` defined at
|
||||
# the top level but `dtolnay/rust-toolchain@1.93.0` hard-coded the same
|
||||
# version inline, creating a drift risk.
|
||||
# Fix: `dtolnay/rust-toolchain@stable` is now called with
|
||||
# `toolchain: ${{ env.RUST_VERSION }}` so there is a single source of truth.
|
||||
#
|
||||
# [F-7] No `timeout-minutes` on long-running build jobs; a hung runner could
|
||||
# block the entire workflow indefinitely.
|
||||
# Fix: added sensible timeouts (60 min for Rust core, 90 min for desktop).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: Deployment environment (staging | production)
|
||||
required: true
|
||||
type: choice
|
||||
default: staging
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
skip_tests:
|
||||
description: Skip pre-deployment tests (use with caution)
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
deploy_platforms:
|
||||
description: |
|
||||
Platforms to deploy (comma-separated).
|
||||
Options: macos-arm64, macos-x64, linux-x64, linux-arm64, windows-x64, docker
|
||||
required: false
|
||||
type: string
|
||||
default: "macos-arm64,macos-x64,linux-x64,linux-arm64,windows-x64,docker"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref }}-${{ inputs.environment || 'staging' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: tinyhumansai/openhuman-core
|
||||
NODE_VERSION: 24.x
|
||||
RUST_VERSION: 1.83.0 # single source of truth — referenced in all toolchain steps
|
||||
|
||||
jobs:
|
||||
# =========================================================================
|
||||
# Phase 0: Validate deployment context
|
||||
# =========================================================================
|
||||
validate:
|
||||
name: Validate deployment context
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: ${{ inputs.environment || 'staging' }}
|
||||
outputs:
|
||||
platforms: ${{ steps.platforms.outputs.list }}
|
||||
ref: ${{ steps.context.outputs.ref }}
|
||||
version: ${{ steps.context.outputs.version }}
|
||||
short_sha: ${{ steps.context.outputs.short_sha }}
|
||||
release_date: ${{ steps.context.outputs.release_date }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Resolve deployment context
|
||||
id: context
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION=$(node -e 'console.log(JSON.parse(require("fs").readFileSync("app/package.json")).version)')
|
||||
SHORT_SHA="${GITHUB_SHA:0:12}"
|
||||
RELEASE_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
|
||||
echo "ref=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "release_date=$RELEASE_DATE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "[deploy] environment=${{ inputs.environment }}"
|
||||
echo "[deploy] ref=${GITHUB_REF_NAME}"
|
||||
echo "[deploy] version=${VERSION}"
|
||||
echo "[deploy] short_sha=${SHORT_SHA}"
|
||||
|
||||
- name: Parse platform filters
|
||||
id: platforms
|
||||
shell: bash
|
||||
env:
|
||||
DEPLOY_PLATFORMS: ${{ inputs.deploy_platforms }}
|
||||
run: |
|
||||
PLATFORMS="${DEPLOY_PLATFORMS:-macos-arm64,macos-x64,linux-x64,linux-arm64,windows-x64,docker}"
|
||||
echo "list=${PLATFORMS}" >> "$GITHUB_OUTPUT"
|
||||
echo "[deploy] platforms=${PLATFORMS}"
|
||||
|
||||
- name: Validate prerequisites
|
||||
shell: bash
|
||||
env:
|
||||
ENVIRONMENT: ${{ inputs.environment }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$ENVIRONMENT" in
|
||||
staging|production)
|
||||
echo "[deploy] Valid environment: $ENVIRONMENT"
|
||||
;;
|
||||
*)
|
||||
echo "[deploy] ERROR: Invalid environment '$ENVIRONMENT'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# =========================================================================
|
||||
# Phase 0b: Resolve dynamic matrix — [F-1]
|
||||
#
|
||||
# matrix.* context is NOT available in job-level `if` expressions; only
|
||||
# step-level expressions can read it. This job converts the
|
||||
# `deploy_platforms` string into explicit JSON include-arrays so that
|
||||
# build-rust-core and build-desktop can use job-level `if` guards safely.
|
||||
# =========================================================================
|
||||
resolve-matrix:
|
||||
name: Resolve build matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs: validate
|
||||
outputs:
|
||||
rust_matrix: ${{ steps.resolve.outputs.rust_matrix }}
|
||||
desktop_matrix: ${{ steps.resolve.outputs.desktop_matrix }}
|
||||
include_docker: ${{ steps.resolve.outputs.include_docker }}
|
||||
steps:
|
||||
- name: Compute include arrays
|
||||
id: resolve
|
||||
shell: bash
|
||||
env:
|
||||
PLATFORMS: ${{ needs.validate.outputs.platforms }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# ── Rust core matrix ──────────────────────────────────────────────
|
||||
RUST_INCLUDES="[]"
|
||||
ALL_RUST='[
|
||||
{"target":"x86_64-unknown-linux-gnu", "runner":"ubuntu-24.04", "platform_filter":"linux-x64"},
|
||||
{"target":"aarch64-unknown-linux-gnu", "runner":"ubuntu-24.04-arm", "platform_filter":"linux-arm64"},
|
||||
{"target":"x86_64-pc-windows-msvc", "runner":"windows-latest", "platform_filter":"windows-x64"},
|
||||
{"target":"aarch64-apple-darwin", "runner":"macos-latest", "platform_filter":"macos-arm64"},
|
||||
{"target":"x86_64-apple-darwin", "runner":"macos-latest", "platform_filter":"macos-x64"}
|
||||
]'
|
||||
RUST_INCLUDES=$(echo "$ALL_RUST" | python3 -c "
|
||||
import json, sys, os
|
||||
platforms = os.environ['PLATFORMS'].split(',')
|
||||
items = json.load(sys.stdin)
|
||||
print(json.dumps([i for i in items if i['platform_filter'] in platforms]))
|
||||
")
|
||||
|
||||
# ── Desktop (Tauri) matrix ─────────────────────────────────────────
|
||||
ALL_DESKTOP='[
|
||||
{"platform":"macos-latest", "args":"--target aarch64-apple-darwin", "target":"aarch64-apple-darwin", "artifact_suffix":"aarch64-apple-darwin", "platform_filter":"macos-arm64"},
|
||||
{"platform":"macos-latest", "args":"--target x86_64-apple-darwin", "target":"x86_64-apple-darwin", "artifact_suffix":"x86_64-apple-darwin", "platform_filter":"macos-x64"},
|
||||
{"platform":"ubuntu-24.04", "args":"--target x86_64-unknown-linux-gnu --bundles deb appimage","target":"x86_64-unknown-linux-gnu", "artifact_suffix":"ubuntu", "platform_filter":"linux-x64"},
|
||||
{"platform":"ubuntu-24.04-arm","args":"--target aarch64-unknown-linux-gnu --bundles deb appimage","target":"aarch64-unknown-linux-gnu", "artifact_suffix":"ubuntu-arm64", "platform_filter":"linux-arm64"},
|
||||
{"platform":"windows-latest", "args":"--target x86_64-pc-windows-msvc", "target":"x86_64-pc-windows-msvc", "artifact_suffix":"windows", "platform_filter":"windows-x64"}
|
||||
]'
|
||||
DESKTOP_INCLUDES=$(echo "$ALL_DESKTOP" | python3 -c "
|
||||
import json, sys, os
|
||||
platforms = os.environ['PLATFORMS'].split(',')
|
||||
items = json.load(sys.stdin)
|
||||
print(json.dumps([i for i in items if i['platform_filter'] in platforms]))
|
||||
")
|
||||
|
||||
# ── Docker flag ────────────────────────────────────────────────────
|
||||
if echo "$PLATFORMS" | grep -q "docker"; then
|
||||
INCLUDE_DOCKER="true"
|
||||
else
|
||||
INCLUDE_DOCKER="false"
|
||||
fi
|
||||
|
||||
echo "rust_matrix={\"include\":${RUST_INCLUDES}}" >> "$GITHUB_OUTPUT"
|
||||
echo "desktop_matrix={\"include\":${DESKTOP_INCLUDES}}" >> "$GITHUB_OUTPUT"
|
||||
echo "include_docker=${INCLUDE_DOCKER}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "[matrix] rust_includes=${RUST_INCLUDES}"
|
||||
echo "[matrix] desktop_includes=${DESKTOP_INCLUDES}"
|
||||
echo "[matrix] include_docker=${INCLUDE_DOCKER}"
|
||||
|
||||
# =========================================================================
|
||||
# Phase 1: Pre-deployment checks (type check, lint)
|
||||
# =========================================================================
|
||||
pre-checks:
|
||||
name: Pre-deployment checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: validate
|
||||
if: ${{ !inputs.skip_tests }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check (TypeScript)
|
||||
run: pnpm compile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
# =========================================================================
|
||||
# Phase 2: Build Rust core binaries
|
||||
# =========================================================================
|
||||
build-rust-core:
|
||||
name: Build Rust core (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
needs: [validate, resolve-matrix, pre-checks]
|
||||
timeout-minutes: 30 # [F-7] prevent hung runners blocking the pipeline
|
||||
# [F-2] proceed when pre-checks succeeded OR was intentionally skipped
|
||||
if: |
|
||||
fromJSON(needs.resolve-matrix.outputs.rust_matrix).include[0] != null &&
|
||||
(needs.pre-checks.result == 'success' || needs.pre-checks.result == 'skipped')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.resolve-matrix.outputs.rust_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install Rust # [F-6] single source of truth via env.RUST_VERSION
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.target }}-
|
||||
|
||||
- name: Build openhuman-core binary
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
OPENHUMAN_BUILD_SHA: ${{ needs.validate.outputs.short_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build \
|
||||
--manifest-path Cargo.toml \
|
||||
--release \
|
||||
--target "$TARGET" \
|
||||
--bin openhuman-core
|
||||
echo "[build] openhuman-core compiled for $TARGET"
|
||||
|
||||
- name: Stage core binary
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/core
|
||||
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
BIN_NAME="openhuman-core.exe"
|
||||
else
|
||||
BIN_NAME="openhuman-core"
|
||||
fi
|
||||
|
||||
cp "target/$TARGET/release/$BIN_NAME" "artifacts/core/$BIN_NAME"
|
||||
ls -lh "artifacts/core/$BIN_NAME"
|
||||
|
||||
- name: Upload core binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core-${{ matrix.target }}
|
||||
path: artifacts/core/
|
||||
retention-days: 7
|
||||
|
||||
# =========================================================================
|
||||
# Phase 3: Build desktop bundles (Tauri + React)
|
||||
# =========================================================================
|
||||
build-desktop:
|
||||
name: Build desktop (${{ matrix.artifact_suffix }})
|
||||
runs-on: ${{ matrix.platform }}
|
||||
needs: [validate, resolve-matrix, pre-checks]
|
||||
timeout-minutes: 30 # [F-7]
|
||||
environment: ${{ inputs.environment || 'staging' }}
|
||||
# [F-2] same skip-guard as build-rust-core
|
||||
if: |
|
||||
fromJSON(needs.resolve-matrix.outputs.desktop_matrix).include[0] != null &&
|
||||
(needs.pre-checks.result == 'success' || needs.pre-checks.result == 'skipped')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.resolve-matrix.outputs.desktop_matrix) }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENHUMAN_TELEGRAM_BOT_USERNAME: ${{ vars.OPENHUMAN_TELEGRAM_BOT_USERNAME }}
|
||||
VITE_TELEGRAM_BOT_USERNAME: ${{ vars.OPENHUMAN_TELEGRAM_BOT_USERNAME }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
submodules: recursive
|
||||
|
||||
- name: Set Xcode version (macOS)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Install Rust # [F-6]
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
targets: ${{ (matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin') || matrix.target }}
|
||||
|
||||
- name: Install Tauri dependencies (Ubuntu)
|
||||
if: startsWith(matrix.platform, 'ubuntu-')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
patchelf cmake libasound2-dev libxdo-dev libxtst-dev libx11-dev libxi-dev \
|
||||
libevdev-dev libssl-dev libclang-dev desktop-file-utils \
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
||||
libgbm1 libpango-1.0-0 libcairo2 libatspi2.0-0 libxshmfence1 libu2f-udev
|
||||
|
||||
- name: Cache Cargo artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
app/src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-tauri-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-tauri-${{ matrix.target }}-
|
||||
|
||||
- name: Cache CEF distribution
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/tauri-cef
|
||||
~/.cache/tauri-cef
|
||||
~/AppData/Local/tauri-cef
|
||||
key: cef-${{ matrix.target }}-${{ hashFiles('app/src-tauri/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
cef-${{ matrix.target }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build desktop application
|
||||
shell: bash
|
||||
working-directory: app
|
||||
env:
|
||||
BASE_URL: ${{ inputs.environment == 'production' && 'https://api.tinyhumans.ai/' || 'https://staging-api.tinyhumans.ai/' }}
|
||||
OPENHUMAN_APP_ENV: ${{ inputs.environment || 'staging' }}
|
||||
VITE_OPENHUMAN_APP_ENV: ${{ inputs.environment || 'staging' }}
|
||||
VITE_BUILD_SHA: ${{ needs.validate.outputs.short_sha }}
|
||||
SENTRY_RELEASE: openhuman@${{ needs.validate.outputs.version }}+${{ needs.validate.outputs.short_sha }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_URL: ${{ vars.SENTRY_URL }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_REACT }}
|
||||
VITE_SENTRY_DSN: ${{ vars.OPENHUMAN_REACT_SENTRY_DSN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
WITH_UPDATER: "true"
|
||||
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.platform == 'macos-latest' && '10.15' || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm tauri:ensure
|
||||
|
||||
if [ "${{ runner.os }}" = "Linux" ]; then
|
||||
NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build \
|
||||
--no-bundle ${{ matrix.args }}
|
||||
CEF_LIB_DIR="$(find "$HOME/.cache/tauri-cef" -name libcef.so -printf '%h\n' 2>/dev/null | head -1)"
|
||||
if [ -z "$CEF_LIB_DIR" ]; then
|
||||
echo "::error::libcef.so not found; cannot satisfy lib4bin ldd resolution."
|
||||
exit 1
|
||||
fi
|
||||
export LD_LIBRARY_PATH="$CEF_LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
fi
|
||||
|
||||
NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build ${{ matrix.args }}
|
||||
echo "[build] Desktop application compiled for ${{ matrix.target }}"
|
||||
|
||||
- name: Sign and notarize macOS bundle
|
||||
if: matrix.platform == 'macos-latest' && inputs.environment == 'production'
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
for var in APPLE_CERTIFICATE_BASE64 APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do
|
||||
if [ -z "${!var}" ]; then
|
||||
echo "::error::Missing macOS signing secret: $var"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
CERT_PATH="${RUNNER_TEMP}/apple_cert.p12"
|
||||
echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH"
|
||||
|
||||
KEYCHAIN_PATH="${RUNNER_TEMP}/build.keychain"
|
||||
security create-keychain -p "$APPLE_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security default-keychain -s "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$APPLE_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$APPLE_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
APP_BUNDLE="$(find app/src-tauri/target/${{ matrix.target }}/release/bundle -name '*.app' -type d | head -1)"
|
||||
|
||||
if [ -z "$APP_BUNDLE" ]; then
|
||||
echo "::warning::No .app bundle found to notarize"
|
||||
else
|
||||
echo "[deploy] Signing $APP_BUNDLE"
|
||||
codesign -s "$APPLE_SIGNING_IDENTITY" --deep --force --verify --verbose "$APP_BUNDLE"
|
||||
|
||||
DMG_FILE="app/src-tauri/target/${{ matrix.target }}/release/bundle/dmg/openhuman.dmg"
|
||||
if [ -f "$DMG_FILE" ]; then
|
||||
echo "[deploy] Notarizing $DMG_FILE"
|
||||
xcrun notarytool submit "$DMG_FILE" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait || echo "::warning::Notarization submitted (may require additional time)"
|
||||
fi
|
||||
fi
|
||||
|
||||
security delete-keychain "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Stage desktop artifacts
|
||||
shell: bash
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/desktop
|
||||
|
||||
if [ -d "app/src-tauri/target/$TARGET/release/bundle" ]; then
|
||||
cp -r app/src-tauri/target/$TARGET/release/bundle/* artifacts/desktop/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
find artifacts/desktop -type f -exec ls -lh {} \;
|
||||
|
||||
- name: Upload desktop artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.artifact_suffix }}
|
||||
path: artifacts/desktop/
|
||||
retention-days: 7
|
||||
|
||||
# =========================================================================
|
||||
# Phase 4: Build and push Docker image
|
||||
# =========================================================================
|
||||
build-docker:
|
||||
name: Build and push Docker image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate, resolve-matrix, pre-checks]
|
||||
timeout-minutes: 30 # [F-7]
|
||||
environment: ${{ inputs.environment || 'staging' }}
|
||||
# [F-2] skip guard; also gated by the docker flag from resolve-matrix
|
||||
if: |
|
||||
needs.resolve-matrix.outputs.include_docker == 'true' &&
|
||||
(needs.pre-checks.result == 'success' || needs.pre-checks.result == 'skipped')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata # [F-4] replaced semver tag with explicit version raw tag
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ needs.validate.outputs.version }}
|
||||
type=raw,value=${{ needs.validate.outputs.version }}-${{ needs.validate.outputs.short_sha }}
|
||||
type=raw,value=${{ inputs.environment || 'staging' }}-${{ needs.validate.outputs.short_sha }}
|
||||
type=ref,event=branch
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# =========================================================================
|
||||
# Phase 5: Publish release assets
|
||||
# =========================================================================
|
||||
publish-release:
|
||||
name: Publish release assets
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [validate, build-rust-core, build-desktop, build-docker]
|
||||
# [F-3] build-docker / build-rust-core / build-desktop may be 'skipped'
|
||||
# when those platform groups were excluded from deploy_platforms — treat
|
||||
# 'skipped' as acceptable, only block on an explicit 'failure'.
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-rust-core.result != 'failure' &&
|
||||
needs.build-desktop.result != 'failure' &&
|
||||
needs.build-docker.result != 'failure'
|
||||
environment: ${{ inputs.environment || 'staging' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist/
|
||||
|
||||
- name: Create checksum manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dist/
|
||||
|
||||
find . -type f \( -name "*.exe" -o -name "*.dmg" -o -name "*.deb" -o -name "*.AppImage" -o -name "*.tar.gz" -o -name "openhuman-core*" \) | while read f; do
|
||||
sha256sum "$f" >> CHECKSUMS.txt
|
||||
done
|
||||
|
||||
echo "::notice::Checksums generated"
|
||||
cat CHECKSUMS.txt || echo "(no artifacts found)"
|
||||
|
||||
- name: Create GitHub Release # [F-5] correct release action version
|
||||
if: inputs.environment == 'production'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.validate.outputs.version }}
|
||||
name: OpenHuman v${{ needs.validate.outputs.version }}
|
||||
body: |
|
||||
# OpenHuman v${{ needs.validate.outputs.version }}
|
||||
|
||||
**Build Info:**
|
||||
- Commit: ${{ github.sha }}
|
||||
- Environment: ${{ inputs.environment }}
|
||||
- Build Date: ${{ needs.validate.outputs.release_date }}
|
||||
|
||||
**Artifacts:**
|
||||
- Desktop applications (macOS, Linux, Windows)
|
||||
- CLI binaries (openhuman-core)
|
||||
- Docker image: `${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.version }}`
|
||||
|
||||
**Checksums:** See attached CHECKSUMS.txt file.
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: dist/CHECKSUMS.txt
|
||||
|
||||
- name: Summary
|
||||
shell: bash
|
||||
run: |
|
||||
echo "## Release Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Environment | ${{ inputs.environment }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | ${{ needs.validate.outputs.version }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Build SHA | ${{ needs.validate.outputs.short_sha }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Build Date | ${{ needs.validate.outputs.release_date }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platforms | ${{ needs.validate.outputs.platforms }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# =========================================================================
|
||||
# Phase 6: Verification
|
||||
# =========================================================================
|
||||
verify:
|
||||
name: Verify deployment
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [validate, publish-release]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check status
|
||||
shell: bash
|
||||
run: |
|
||||
echo "[deploy] Deployment complete"
|
||||
echo " Version: ${{ needs.validate.outputs.version }}"
|
||||
echo " Platforms: ${{ needs.validate.outputs.platforms }}"
|
||||
echo " Release: ${{ needs.publish-release.result }}"
|
||||
@@ -1,128 +0,0 @@
|
||||
name: Uptime Monitor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *' # Run every 5 minutes
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
concurrency:
|
||||
group: uptime-monitor
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-uptime:
|
||||
name: Check Backend Health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Uptime Check Script
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
ALERT_WEBHOOK_URL: ${{ secrets.ALERT_WEBHOOK_URL }}
|
||||
with:
|
||||
script: |
|
||||
const endpoints = [
|
||||
{ env: "Production", url: "https://api.tinyhumans.ai/health", timeout: 10000 },
|
||||
{ env: "Staging", url: "https://staging-api.tinyhumans.ai/health", timeout: 15000 }
|
||||
];
|
||||
|
||||
const maxRetries = 3;
|
||||
const retryDelay = 5000;
|
||||
const issueTitle = "CRITICAL: Backend Outage Detected";
|
||||
|
||||
async function fetchWithRetry(url, timeout, retries) {
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeout);
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(id);
|
||||
|
||||
if (response.status === 200) return { ok: true, status: response.status };
|
||||
if (i === retries) return { ok: false, status: response.status };
|
||||
} catch (error) {
|
||||
if (i === retries) return { ok: false, error: error.message };
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
}
|
||||
}
|
||||
|
||||
let allHealthy = true;
|
||||
let failureDetails = [];
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const result = await fetchWithRetry(ep.url, ep.timeout, maxRetries);
|
||||
if (!result.ok) {
|
||||
allHealthy = false;
|
||||
failureDetails.push(`- **${ep.env}**: ${ep.url} (Status: ${result.status || result.error}) at ${new Date().toISOString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
creator: 'github-actions[bot]'
|
||||
});
|
||||
const openIssue = issues.find(i => i.title === issueTitle);
|
||||
|
||||
const webhookUrl = process.env.ALERT_WEBHOOK_URL;
|
||||
async function sendWebhook(message) {
|
||||
if (webhookUrl) {
|
||||
try {
|
||||
// Discord uses { content } and Slack uses { text }; detect by URL
|
||||
const payload = webhookUrl.includes('discord')
|
||||
? { content: message }
|
||||
: { text: message };
|
||||
await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send webhook", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allHealthy) {
|
||||
if (openIssue) {
|
||||
const recoveryMsg = `✅ **RESOLVED**: All backend endpoints are now healthy and reachable.\n\nMonitors reported recovery at ${new Date().toISOString()}.`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: openIssue.number,
|
||||
body: recoveryMsg
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: openIssue.number,
|
||||
state: 'closed'
|
||||
});
|
||||
await sendWebhook(recoveryMsg);
|
||||
console.log("Services recovered. Issue closed.");
|
||||
} else {
|
||||
console.log("All services healthy.");
|
||||
}
|
||||
} else {
|
||||
const issueBody = `The automated uptime monitor detected an outage in the backend.\n\n### Failing Endpoints:\n${failureDetails.join('\n')}\n\nPlease check the logs and follow the runbook in \`docs/OPERATIONS.md\`.`;
|
||||
|
||||
if (!openIssue) {
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: issueTitle,
|
||||
body: issueBody,
|
||||
labels: ['bug', 'critical', 'ops']
|
||||
});
|
||||
await sendWebhook(`🚨 **${issueTitle}**\n${issueBody}`);
|
||||
core.setFailed("Backend health check failed.");
|
||||
} else {
|
||||
console.log("Issue already exists, skipping duplicate creation.");
|
||||
core.setFailed("Backend health check failed (ongoing).");
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
name: Weekly Code Review
|
||||
|
||||
# Scheduled aggregation of slow-moving code-health signals that per-PR CI
|
||||
# does not catch: unused code (knip), Rust advisories (cargo-audit), and
|
||||
# TODO/FIXME backlog. The run opens (or updates) a tracking issue with the
|
||||
# report and uploads the raw outputs as an artifact.
|
||||
#
|
||||
# Runbook: docs/WEEKLY-CODE-REVIEW.md
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays, 06:00 UTC. Early enough to land before US / EU maintainers
|
||||
# start the week. Override via workflow_dispatch if needed.
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: weekly-code-review
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
weekly-review:
|
||||
name: Aggregate weekly signals
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.local/share/pnpm/store
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache cargo-audit binary
|
||||
id: cache-cargo-audit
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
# Image sets CARGO_HOME=/usr/local/cargo, so cargo install drops the
|
||||
# binary there — not in $HOME/.cargo/bin.
|
||||
path: /usr/local/cargo/bin/cargo-audit
|
||||
key: cargo-audit-${{ runner.os }}-v1
|
||||
|
||||
- name: Install cargo-audit
|
||||
if: steps.cache-cargo-audit.outputs.cache-hit != 'true'
|
||||
run: cargo install cargo-audit --locked
|
||||
|
||||
- name: Run weekly code-review aggregator
|
||||
run: bash scripts/weekly-code-review.sh weekly-code-review-out
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: weekly-code-review-${{ github.run_id }}
|
||||
path: weekly-code-review-out
|
||||
retention-days: 90
|
||||
|
||||
- name: Open or update tracking issue
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const body = fs.readFileSync('weekly-code-review-out/report.md', 'utf8');
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const title = `[Automated] Weekly code-review report — ${today}`;
|
||||
const label = 'weekly-code-review';
|
||||
|
||||
// Ensure the tracking label exists (idempotent).
|
||||
try {
|
||||
await github.rest.issues.getLabel({ ...context.repo, name: label });
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
await github.rest.issues.createLabel({
|
||||
...context.repo,
|
||||
name: label,
|
||||
color: 'c5def5',
|
||||
description: 'Automated weekly code-review report',
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Close previous open report(s) so only the latest stays active.
|
||||
const previous = await github.paginate(github.rest.issues.listForRepo, {
|
||||
...context.repo,
|
||||
state: 'open',
|
||||
labels: label,
|
||||
per_page: 50,
|
||||
});
|
||||
for (const prev of previous) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: prev.number,
|
||||
body: `Superseded by the ${today} report.`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
...context.repo,
|
||||
issue_number: prev.number,
|
||||
state: 'closed',
|
||||
state_reason: 'completed',
|
||||
});
|
||||
}
|
||||
|
||||
// Open a fresh issue for this week so maintainers triage on a
|
||||
// predictable cadence instead of watching a growing thread.
|
||||
const runUrl = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const footer = `\n---\n_Run log: ${runUrl}_`;
|
||||
await github.rest.issues.create({
|
||||
...context.repo,
|
||||
title,
|
||||
body: body + footer,
|
||||
labels: [label],
|
||||
});
|
||||
Reference in New Issue
Block a user