Merge pull request #1 from mempool/knorrium/shared_gha

Add shared actions and workflows
This commit is contained in:
Felipe Knorr Kuhn 2026-02-15 16:50:02 -08:00 committed by GitHub
commit 0524095ab1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 657 additions and 0 deletions

View File

@ -0,0 +1,37 @@
name: 'Compute Asset Cache Hash'
description: 'Compute hash from mining-pool-logos and mempool-promo HEAD SHAs for cache keys'
inputs:
github-token:
description: 'GitHub token for API access'
required: true
cache-version:
description: 'Cache version fallback when remote SHA lookup fails'
required: false
default: 'v1'
outputs:
hash:
description: 'Hash used for cache keys (12-char sha256 of both SHAs or cache-version)'
value: ${{ steps.compute-hash.outputs.hash }}
runs:
using: 'composite'
steps:
- name: Compute asset cache key from remote SHAs
id: compute-hash
shell: bash
env:
FALLBACK_VERSION: ${{ inputs.cache-version }}
run: |
AUTH_HEADER="Authorization: Bearer ${{ inputs.github-token }}"
LOGOS_SHA=$(curl -sf -H "$AUTH_HEADER" -H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/mempool/mining-pool-logos/commits/HEAD" 2>/dev/null | jq -r '.sha // empty') || true
PROMO_SHA=$(curl -sf -H "$AUTH_HEADER" -H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/mempool/mempool-promo/commits/HEAD" 2>/dev/null | jq -r '.sha // empty') || true
if [[ -n "$LOGOS_SHA" && -n "$PROMO_SHA" ]]; then
HASH=$(echo -n "${LOGOS_SHA}${PROMO_SHA}" | sha256sum | cut -c1-12)
else
HASH="$FALLBACK_VERSION"
fi
echo "hash=$HASH" >> $GITHUB_OUTPUT

View File

@ -0,0 +1,77 @@
name: 'Lint, Test, and Build'
description: 'Run lint, test, and build steps for Node.js projects. Script inputs must be npm script names only and should only be set by trusted workflow authors.'
inputs:
working-directory:
description: 'Working directory for commands'
required: true
flavor:
description: 'Build flavor (dev or prod) - lint and test only run for dev'
required: false
default: 'dev'
run-lint:
description: 'Whether to run lint step'
required: false
default: 'true'
lint-script:
description: 'npm script name for linting (e.g. from package.json). Must be a single script name; set only from trusted workflow authors.'
required: false
default: 'lint'
run-test:
description: 'Whether to run test step'
required: false
default: 'true'
test-script:
description: 'npm script name for testing (e.g. from package.json). Must be a single script name; set only from trusted workflow authors.'
required: false
default: 'test'
run-build:
description: 'Whether to run build step'
required: false
default: 'true'
build-script:
description: 'npm script name for building (e.g. from package.json). Must be a single script name; set only from trusted workflow authors.'
required: false
default: 'build'
runs:
using: 'composite'
steps:
- name: Lint
if: inputs.run-lint == 'true' && inputs.flavor == 'dev'
shell: bash
env:
LINT_SCRIPT: ${{ inputs.lint-script }}
run: |
if [[ ! "$LINT_SCRIPT" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "::error::lint-script must match ^[a-zA-Z0-9_-]+$ (got: $LINT_SCRIPT)"
exit 1
fi
npm run "$LINT_SCRIPT"
working-directory: ${{ inputs.working-directory }}
- name: Test
if: inputs.run-test == 'true' && inputs.flavor == 'dev'
shell: bash
env:
TEST_SCRIPT: ${{ inputs.test-script }}
run: |
if [[ ! "$TEST_SCRIPT" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "::error::test-script must match ^[a-zA-Z0-9_-]+$ (got: $TEST_SCRIPT)"
exit 1
fi
npm run "$TEST_SCRIPT"
working-directory: ${{ inputs.working-directory }}
- name: Build
if: inputs.run-build == 'true'
shell: bash
env:
BUILD_SCRIPT: ${{ inputs.build-script }}
run: |
if [[ ! "$BUILD_SCRIPT" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "::error::build-script must match ^[a-zA-Z0-9_-]+$ (got: $BUILD_SCRIPT)"
exit 1
fi
npm run "$BUILD_SCRIPT"
working-directory: ${{ inputs.working-directory }}

View File

@ -0,0 +1,70 @@
name: 'Node.js CI Setup'
description: 'Checkout repository, setup Node.js, cache node_modules, and install dependencies'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '24.13.0'
checkout-path:
description: 'Path to checkout the repository'
required: false
default: '.'
working-directory:
description: 'Working directory for npm install (relative to checkout-path)'
required: true
flavor:
description: 'Build flavor (dev or prod)'
required: false
default: 'dev'
cache-prefix:
description: 'Prefix for cache key'
required: false
default: 'node'
checkout-submodules:
description: 'Whether to checkout submodules'
required: false
default: 'false'
checkout-ref:
description: 'Git ref to checkout (branch, tag, or SHA)'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
path: ${{ inputs.checkout-path }}
# When checkout-ref is empty, actions/checkout uses the default github.ref
ref: ${{ inputs.checkout-ref }}
submodules: ${{ inputs.checkout-submodules }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: '${{ inputs.checkout-path }}/${{ inputs.working-directory }}/package-lock.json'
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ${{ inputs.checkout-path }}/${{ inputs.working-directory }}/node_modules
key: ${{ runner.os }}-${{ inputs.cache-prefix }}-${{ inputs.flavor }}-node-${{ inputs.node-version }}-${{ hashFiles(format('{0}/{1}/package-lock.json', inputs.checkout-path, inputs.working-directory)) }}
restore-keys: |
${{ runner.os }}-${{ inputs.cache-prefix }}-${{ inputs.flavor }}-node-${{ inputs.node-version }}-
${{ runner.os }}-${{ inputs.cache-prefix }}-${{ inputs.flavor }}-
- name: Install dependencies (dev)
if: inputs.flavor == 'dev'
shell: bash
run: npm ci
working-directory: ${{ inputs.checkout-path }}/${{ inputs.working-directory }}
- name: Install dependencies (prod)
if: inputs.flavor == 'prod'
shell: bash
run: npm ci --omit=dev --omit=optional
working-directory: ${{ inputs.checkout-path }}/${{ inputs.working-directory }}

View File

@ -0,0 +1,92 @@
name: 'Restore Cached Assets'
description: 'Restore cached mining pool and promo video assets from cache and/or artifacts'
inputs:
frontend-path:
description: 'Path to frontend directory'
required: true
github-token:
description: 'GitHub token for API access (used to compute cache key from remote SHAs)'
required: true
use-artifacts:
description: 'Whether to download from artifacts (requires cache job to have run first)'
required: false
default: 'true'
cache-version:
description: 'Cache version fallback when remote SHA lookup fails'
required: false
default: 'v1'
outputs:
mining-pool-cache-hit:
description: 'Whether mining pool assets were found in cache'
value: ${{ steps.cache-mining-pool-restore.outputs.cache-hit }}
promo-video-cache-hit:
description: 'Whether promo video assets were found in cache'
value: ${{ steps.cache-promo-video-restore.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Compute asset cache key from remote SHAs
id: asset-hash
uses: ./.github/actions/compute-asset-hash
with:
github-token: ${{ inputs.github-token }}
cache-version: ${{ inputs.cache-version }}
- name: Restore cached mining pool assets
continue-on-error: true
id: cache-mining-pool-restore
uses: actions/cache/restore@v4
with:
path: mining-pool-assets.zip
key: mining-pool-assets-${{ steps.asset-hash.outputs.hash }}
restore-keys: |
mining-pool-assets-
- name: Restore cached promo video assets
continue-on-error: true
id: cache-promo-video-restore
uses: actions/cache/restore@v4
with:
path: promo-video-assets.zip
key: promo-video-assets-${{ steps.asset-hash.outputs.hash }}
restore-keys: |
promo-video-assets-
- name: Download mining pool artifact
continue-on-error: true
if: fromJSON(inputs.use-artifacts)
uses: actions/download-artifact@v4
with:
name: mining-pool-assets
- name: Download promo video artifact
continue-on-error: true
if: fromJSON(inputs.use-artifacts)
uses: actions/download-artifact@v4
with:
name: promo-video-assets
- name: Unzip mining pool assets
shell: bash
continue-on-error: true
run: |
if [ -f "mining-pool-assets.zip" ]; then
echo "Found mining-pool-assets.zip, unzipping..."
unzip -o mining-pool-assets.zip -d "${{ inputs.frontend-path }}/src/resources/mining-pools"
else
echo "mining-pool-assets.zip not found; skipping unzip."
fi
- name: Unzip promo video assets
shell: bash
continue-on-error: true
run: |
if [ -f "promo-video-assets.zip" ]; then
echo "Found promo-video-assets.zip, unzipping..."
unzip -o promo-video-assets.zip -d "${{ inputs.frontend-path }}/src/resources/promo-video"
else
echo "promo-video-assets.zip not found; skipping unzip."
fi

View File

@ -0,0 +1,58 @@
name: 'Setup Rust Toolchain'
description: 'Read rust-toolchain file, cache Rust dependencies, and install the toolchain'
inputs:
working-directory:
description: 'Working directory (repository root)'
required: true
rust-toolchain-path:
description: 'Path to rust-toolchain file relative to working-directory'
required: false
default: 'rust/gbt/rust-toolchain'
cargo-lock-path:
description: 'Path pattern for Cargo.lock files relative to working-directory'
required: false
default: 'rust/gbt/**/Cargo.lock'
rust-target-path:
description: 'Path to Rust target directory relative to working-directory'
required: false
default: 'rust/gbt/target/'
flavor:
description: 'Build flavor for cache key differentiation'
required: false
default: 'dev'
outputs:
toolchain:
description: 'The Rust toolchain version that was installed'
value: ${{ steps.gettoolchain.outputs.toolchain }}
runs:
using: 'composite'
steps:
- name: Read rust-toolchain file
id: gettoolchain
shell: bash
run: echo "toolchain=$(cat ./${{ inputs.rust-toolchain-path }})" >> $GITHUB_OUTPUT
working-directory: ${{ inputs.working-directory }}
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ inputs.working-directory }}/${{ inputs.rust-target-path }}
key: ${{ runner.os }}-cargo-${{ inputs.flavor }}-${{ hashFiles(format('{0}/{1}', inputs.working-directory, inputs.cargo-lock-path)) }}
restore-keys: |
${{ runner.os }}-cargo-${{ inputs.flavor }}-
${{ runner.os }}-cargo-
- name: Install Rust toolchain
# dtolnay/rust-toolchain pinned to a specific commit SHA for supply chain security.
# To identify or update the corresponding version/tag, check this SHA in the dtolnay/rust-toolchain repository.
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561
with:
toolchain: ${{ steps.gettoolchain.outputs.toolchain }}

View File

@ -0,0 +1,122 @@
name: 'Sync and Cache Assets'
description: 'Sync assets from CDN, zip, upload as artifacts, and save to cache. Script input must be an npm script name only and should only be set by trusted workflow authors.'
inputs:
frontend-path:
description: 'Path to frontend directory'
required: true
github-token:
description: 'GitHub token for API access'
required: true
sync-script:
description: 'npm script name for syncing assets (e.g. from package.json). Must be a single script name; set only from trusted workflow authors.'
required: false
default: 'sync-assets-dev'
cache-version:
description: 'Cache version fallback when remote SHA lookup fails'
required: false
default: 'v1'
outputs:
cache-key-hash:
description: 'Hash used for cache keys (from remote SHAs or cache-version fallback)'
value: ${{ steps.asset-hash.outputs.hash }}
runs:
using: 'composite'
steps:
- name: Compute asset cache key from remote SHAs
id: asset-hash
uses: ./.github/actions/compute-asset-hash
with:
github-token: ${{ inputs.github-token }}
cache-version: ${{ inputs.cache-version }}
# Cache miss is success with cache-hit=false; only real errors (network, permissions) fail the step.
- name: Restore cached mining pool assets
id: cache-mining-pool-restore
uses: actions/cache/restore@v4
with:
path: mining-pool-assets.zip
key: mining-pool-assets-${{ steps.asset-hash.outputs.hash }}
restore-keys: |
mining-pool-assets-
# Cache miss is success with cache-hit=false; only real errors (network, permissions) fail the step.
- name: Restore cached promo video assets
id: cache-promo-video-restore
uses: actions/cache/restore@v4
with:
path: promo-video-assets.zip
key: promo-video-assets-${{ steps.asset-hash.outputs.hash }}
restore-keys: |
promo-video-assets-
- name: Unzip mining pool assets before sync
continue-on-error: true
shell: bash
run: |
if [[ -f "mining-pool-assets.zip" ]]; then
unzip -o mining-pool-assets.zip -d "${{ inputs.frontend-path }}/src/resources/mining-pools"
else
echo "No cached mining pool assets zip found; skipping unzip."
fi
- name: Unzip promo video assets before sync
continue-on-error: true
shell: bash
run: |
if [[ -f "promo-video-assets.zip" ]]; then
unzip -o promo-video-assets.zip -d "${{ inputs.frontend-path }}/src/resources/promo-video"
else
echo "No cached promo video assets zip found; skipping unzip."
fi
- name: Sync assets
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
MEMPOOL_CDN: 1
VERBOSE: 1
SYNC_SCRIPT: ${{ inputs.sync-script }}
run: |
if [[ ! "$SYNC_SCRIPT" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "::error::sync-script must match ^[a-zA-Z0-9_-]+$ (got: $SYNC_SCRIPT)"
exit 1
fi
npm run "$SYNC_SCRIPT"
working-directory: ${{ inputs.frontend-path }}
- name: Zip mining pool assets
shell: bash
run: zip -jrq mining-pool-assets.zip ${{ inputs.frontend-path }}/src/resources/mining-pools/*
- name: Zip promo video assets
shell: bash
run: zip -jrq promo-video-assets.zip ${{ inputs.frontend-path }}/src/resources/promo-video/*
- name: Upload mining pool assets artifact
uses: actions/upload-artifact@v4
with:
name: mining-pool-assets
path: mining-pool-assets.zip
- name: Upload promo video assets artifact
uses: actions/upload-artifact@v4
with:
name: promo-video-assets
path: promo-video-assets.zip
- name: Save mining pool assets cache
if: steps.cache-mining-pool-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: mining-pool-assets.zip
key: mining-pool-assets-${{ steps.asset-hash.outputs.hash }}
- name: Save promo video assets cache
if: steps.cache-promo-video-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: promo-video-assets.zip
key: promo-video-assets-${{ steps.asset-hash.outputs.hash }}

View File

@ -0,0 +1,201 @@
# Reusable workflow: Automate project board management
# - Add newly created issues to project board
# - Set status to "Review Needed" when a reviewer is requested on a non-draft PR
name: Project Board Automation
on:
workflow_call:
inputs:
project-number:
description: 'The project board number'
required: false
type: number
default: 8
runs-on:
description: 'Runner to use for the job'
required: false
type: string
default: 'ubuntu-latest'
handle-issues:
description: 'Whether to handle adding issues to project board'
required: false
type: boolean
default: true
secrets:
PROJECT_TOKEN:
description: 'PAT with project write access'
required: true
PROJECT_ID:
description: 'The project unique identifier'
required: true
STATUS_FIELD_ID:
description: 'The Status field unique identifier'
required: true
REVIEW_NEEDED_OPTION_ID:
description: 'The Review Needed option unique identifier'
required: true
jobs:
manage-project-board:
runs-on: ${{ inputs.runs-on }}
steps:
- name: Update Project Board
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PROJECT_TOKEN }}
script: |
const projectNumber = ${{ inputs.project-number }};
const handleIssues = ${{ inputs.handle-issues }};
// Skip draft PRs
if (
context.eventName === 'pull_request' &&
context.payload &&
context.payload.pull_request &&
context.payload.pull_request.draft
) {
console.log('PR is a draft, skipping Review Needed status...');
return;
}
// Handle new issues - add to project
if (context.eventName === 'issues' && handleIssues) {
const addMutation = `
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item { id }
}
}
`;
try {
await github.graphql(addMutation, {
projectId: "${{ secrets.PROJECT_ID }}",
contentId: context.payload.issue.node_id
});
console.log(`Successfully added issue to project #${projectNumber}`);
} catch (error) {
const errors = error?.errors ?? [];
const isAlreadyInProject = errors.some(e => {
if (typeof e.message !== 'string') return false;
// Only handle errors from our mutation
const path = e.path ?? [];
const fromOurMutation = Array.isArray(path) && path.includes('addProjectV2ItemById');
if (!fromOurMutation) return false;
const isUnprocessable = e.extensions?.code === 'UNPROCESSABLE';
if (!isUnprocessable) return false;
// Match the known GitHub error phrasing for duplicate items
const msg = e.message.toLowerCase();
return /already\s+(exists\s+)?in\s+(this\s+)?project\b/i.test(msg);
});
if (isAlreadyInProject) {
console.log(`Issue is already in project #${projectNumber}, skipping add.`);
} else {
console.error(`Failed to add issue to project #${projectNumber}:`, error);
throw error;
}
}
return;
}
// Handle PR review_requested - update status to "Review Needed"
if (context.eventName === 'pull_request') {
// GraphQL query to find the PR's project items
const query = `
query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
projectItems(first: 50, after: $cursor) {
nodes {
id
project {
number
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
`;
// Paginate through all project items for the PR
const MAX_PAGES = 100;
let projectItems = [];
let cursor = null;
let hasNextPage = true;
let page = 0;
while (hasNextPage && page < MAX_PAGES) {
page += 1;
const result = await github.graphql(query, {
owner: context.repo.owner,
repo: context.repo.repo,
pr: context.payload.pull_request.number,
cursor
});
const projectItemsConnection = result.repository.pullRequest.projectItems;
projectItems = projectItems.concat(projectItemsConnection.nodes || []);
hasNextPage = projectItemsConnection.pageInfo.hasNextPage;
cursor = projectItemsConnection.pageInfo.endCursor;
}
if (hasNextPage) {
console.warn(`Stopped after ${MAX_PAGES} pages; more project items may exist.`);
const message = `Stopped after ${MAX_PAGES} pages; more project items may exist. Failing to avoid using incomplete project data.`;
console.error(message);
throw new Error(message);
}
// Find the project item that belongs to the target project
const projectItem = projectItems.find(item => item.project.number === projectNumber);
// Exit early if PR isn't in the target project
if (!projectItem) {
console.log(`PR is not in project #${projectNumber}, skipping...`);
return;
}
// GraphQL mutation to update the Status field
const mutation = `
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}
) {
projectV2Item {
id
}
}
}
`;
// Execute the mutation with error handling for better debugging
try {
await github.graphql(mutation, {
projectId: "${{ secrets.PROJECT_ID }}",
itemId: projectItem.id,
fieldId: "${{ secrets.STATUS_FIELD_ID }}",
optionId: "${{ secrets.REVIEW_NEEDED_OPTION_ID }}"
});
console.log('Successfully updated project status to Review Needed');
} catch (error) {
console.error(
`Failed to update project status to Review Needed for project #${projectNumber} and item ${projectItem.id}:`,
error
);
throw error;
}
}
}