diff --git a/actions/compute-asset-hash/action.yml b/actions/compute-asset-hash/action.yml new file mode 100644 index 0000000..e7c5831 --- /dev/null +++ b/actions/compute-asset-hash/action.yml @@ -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 diff --git a/actions/lint-test-build/action.yml b/actions/lint-test-build/action.yml new file mode 100644 index 0000000..af3f747 --- /dev/null +++ b/actions/lint-test-build/action.yml @@ -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 }} diff --git a/actions/node-ci/action.yml b/actions/node-ci/action.yml new file mode 100644 index 0000000..cdb3762 --- /dev/null +++ b/actions/node-ci/action.yml @@ -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 }} diff --git a/actions/restore-assets/action.yml b/actions/restore-assets/action.yml new file mode 100644 index 0000000..bcffedb --- /dev/null +++ b/actions/restore-assets/action.yml @@ -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 diff --git a/actions/setup-rust/action.yml b/actions/setup-rust/action.yml new file mode 100644 index 0000000..091f8b0 --- /dev/null +++ b/actions/setup-rust/action.yml @@ -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 }} diff --git a/actions/sync-assets/action.yml b/actions/sync-assets/action.yml new file mode 100644 index 0000000..c079d33 --- /dev/null +++ b/actions/sync-assets/action.yml @@ -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 }} diff --git a/workflows/project-board-automation.yml b/workflows/project-board-automation.yml new file mode 100644 index 0000000..e434542 --- /dev/null +++ b/workflows/project-board-automation.yml @@ -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; + } + } + }