diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 869de9832..df1479960 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ name: CI + on: push: branches: @@ -6,6 +7,11 @@ on: - main pull_request: +# Cancel in-progress PR runs; let main builds finish so the Nx Cloud cache is populated. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # Needed for nx-set-shas when run on the main branch permissions: actions: read @@ -31,29 +37,69 @@ jobs: # the PR is supposed to remove. nrwl/nx-set-shas still computes the correct # affected range from git history. Falls back to github.sha for push events. ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Check for code changes + id: changes + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.sha }}" + fi + + if [ "$BASE" = "0000000000000000000000000000000000000000" ]; then + echo "has-code-changes=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if git diff --name-only "$BASE" "$HEAD" | grep -qvE '^website/|^docs/|\.md$|^netlify\.toml$'; then + echo "has-code-changes=true" >> "$GITHUB_OUTPUT" + else + echo "has-code-changes=false" >> "$GITHUB_OUTPUT" + echo "Skipping heavy CI steps: only docs/website/markdown files changed." + fi - uses: actions/setup-node@v7 + if: steps.changes.outputs.has-code-changes == 'true' with: node-version: lts/* - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + if: steps.changes.outputs.has-code-changes == 'true' with: bun-version: 1.3.14 + - name: Cache bun dependencies + if: steps.changes.outputs.has-code-changes == 'true' + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- # This line enables distribution # The "--stop-agents-after" is optional, but allows idle agents to shut down once the "e2e-ci" targets have been requested # - run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e-ci" - run: bun install + if: steps.changes.outputs.has-code-changes == 'true' - uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5 + if: steps.changes.outputs.has-code-changes == 'true' - name: Test tool-call hook scripts + if: steps.changes.outputs.has-code-changes == 'true' run: bash .github/hooks/scripts/test-hooks.sh shell: bash - name: Print Environment Info - run: npx nx report + if: steps.changes.outputs.has-code-changes == 'true' + run: ./node_modules/.bin/nx report shell: bash - - run: npx nx format:check - - run: npx nx affected -t lint test build e2e-ci --verbose=false --parallel=3 - if: ${{ env.NX_CLOUD_ACCESS_TOKEN != '' }} - - run: npx nx affected -t lint test build e2e-ci --verbose=false --parallel=3 --no-cloud - if: ${{ env.NX_CLOUD_ACCESS_TOKEN == '' }} - - run: npx nx-cloud fix-ci - if: always() && env.NX_CLOUD_ACCESS_TOKEN != '' + - name: Check formatting for changed files + if: steps.changes.outputs.has-code-changes == 'true' + run: ./node_modules/.bin/nx format:check --base=$NX_BASE --head=$NX_HEAD + - name: Run affected lint, test, build and e2e-ci + if: ${{ env.NX_CLOUD_ACCESS_TOKEN != '' && steps.changes.outputs.has-code-changes == 'true' }} + run: ./node_modules/.bin/nx affected -t lint test build e2e-ci --verbose=false --parallel=3 + - run: ./node_modules/.bin/nx affected -t lint test build e2e-ci --verbose=false --parallel=3 --no-cloud + if: ${{ env.NX_CLOUD_ACCESS_TOKEN == '' && steps.changes.outputs.has-code-changes == 'true' }} + - run: ./node_modules/.bin/nx-cloud fix-ci + if: always() && env.NX_CLOUD_ACCESS_TOKEN != '' && steps.changes.outputs.has-code-changes == 'true' diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 026f0a6b6..7bce9bf28 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -10,6 +10,11 @@ on: paths: - .github/workflows/copilot-setup-steps.yml +# Cancel any in-progress runs for the same workflow/ref combination. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: # Must be named exactly `copilot-setup-steps` for Copilot to pick it up. copilot-setup-steps: @@ -25,6 +30,13 @@ jobs: uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.14 + - name: Cache bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- - name: Install dependencies run: bun install diff --git a/netlify.toml b/netlify.toml index 2182976dd..e4dfa1b94 100644 --- a/netlify.toml +++ b/netlify.toml @@ -19,7 +19,13 @@ # which gets reported as a red `error` state + failing GitHub commit # status. Exit 0 from this command tells Netlify to skip the deploy # (reported as `skipped`, not `error`). Exit 1 means "proceed". - ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- . ../netlify.toml" + # + # The script runs from the `website/` base directory. It checks the + # Netlify cached diff first, then falls back to the GitHub API for + # pull requests so deploy previews are skipped unless the PR actually + # touches the docs site or `netlify.toml`. For private repos, set the + # `GITHUB_TOKEN` environment variable in the Netlify UI. + ignore = "node ./scripts/netlify-ignore.cjs" [build.environment] NODE_VERSION = "20" diff --git a/website/scripts/netlify-ignore.cjs b/website/scripts/netlify-ignore.cjs new file mode 100644 index 000000000..b90241fc1 --- /dev/null +++ b/website/scripts/netlify-ignore.cjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * Netlify ignore script for the Docusaurus site under website/. + * + * Exit 0 -> skip the build (no deploy preview/production build needed). + * Exit 1 -> run the build (website/ or netlify.toml changed). + * + * The script is executed by Netlify from the `website/` base directory. + */ + +const { spawnSync } = require('node:child_process'); +const https = require('node:https'); + +const CONTEXT = process.env.CONTEXT || ''; +const IS_PULL_REQUEST = process.env.PULL_REQUEST === 'true'; +const REVIEW_ID = process.env.REVIEW_ID || ''; +const COMMIT_REF = process.env.COMMIT_REF || ''; +const CACHED_COMMIT_REF = process.env.CACHED_COMMIT_REF || ''; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; + +const REPO = 'abapify/adt-cli'; + +function hasWebsiteRelevantPath(filePath) { + if (!filePath) return false; + return filePath === 'netlify.toml' || filePath.startsWith('website/'); +} + +function gitDiffChangedFiles(refA, refB) { + const result = spawnSync( + '/usr/bin/git', + ['diff', '--name-only', refA, refB, '--', '.', '../netlify.toml'], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, PATH: '/usr/local/bin:/usr/bin:/bin' }, + shell: false, + }, + ); + if (result.error || result.status !== 0) { + return null; + } + return result.stdout.trim().split('\n').filter(Boolean); +} + +function fetchJson(url) { + return new Promise((resolve, reject) => { + const options = { headers: { 'User-Agent': 'netlify-ignore-script' } }; + if (GITHUB_TOKEN) { + options.headers.Authorization = `token ${GITHUB_TOKEN}`; + } + https + .get(url, options, (res) => { + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`GitHub API returned ${res.statusCode}`)); + return; + } + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(err); + } + }); + }) + .on('error', reject); + }); +} + +async function prTouchesWebsite() { + const reviewId = Number(REVIEW_ID); + if (!Number.isInteger(reviewId) || reviewId <= 0) { + throw new Error(`Invalid REVIEW_ID: ${REVIEW_ID}`); + } + const pageSize = 100; + let page = 1; + while (true) { + const files = await fetchJson( + `https://api.github.com/repos/${REPO}/pulls/${reviewId}/files?per_page=${pageSize}&page=${page}`, + ); + if (!Array.isArray(files) || files.length === 0) return false; + for (const file of files) { + if (hasWebsiteRelevantPath(file.filename)) return true; + } + if (files.length < pageSize) return false; + page += 1; + } +} + +async function main() { + // Fast path: we have two distinct cached refs. If nothing in website/ or + // netlify.toml changed, skip the build. + if (COMMIT_REF && CACHED_COMMIT_REF && CACHED_COMMIT_REF !== COMMIT_REF) { + const files = gitDiffChangedFiles(CACHED_COMMIT_REF, COMMIT_REF); + if (files?.length > 0) { + console.log( + `Building: ${files.length} change(s) in website/ or netlify.toml`, + ); + process.exit(1); + } + if (files?.length === 0) { + console.log( + 'Skipping: no website/ or netlify.toml changes since the last build', + ); + process.exit(0); + } + // git diff failed; fall through to PR API or default build. + } + + // For pull/merge request previews, ask GitHub which files changed. + if ((IS_PULL_REQUEST || CONTEXT === 'deploy-preview') && REVIEW_ID) { + try { + const touches = await prTouchesWebsite(); + if (!touches) { + console.log( + 'Skipping deploy preview: PR does not touch website/ or netlify.toml', + ); + process.exit(0); + } + console.log( + 'Building deploy preview: PR touches website/ or netlify.toml', + ); + process.exit(1); + } catch (err) { + // If we cannot determine the changed files, build to be safe. + console.warn( + `Could not determine PR changed files (${err.message}); building to be safe`, + ); + process.exit(1); + } + } + + // Production / branch-deploy without useful cached refs or PR info: build. + console.log('Building: no cached diff or PR information available'); + process.exit(1); +} + +main();