Skip to content

Commit 53e6972

Browse files
authored
Merge pull request #3274 from uswds/task/circleci-speedup-spike
USWDS-Site: Speed up CircleCI build
2 parents 7ac0f8f + 098fa56 commit 53e6972

3 files changed

Lines changed: 169 additions & 15 deletions

File tree

.circleci/config.yml

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ jobs:
1111
executor: my-executor
1212
steps:
1313
- checkout
14+
# Fallback keys allow partial cache hits when the lock file changes.
15+
# Bump v1- to invalidate all caches.
1416
- restore_cache:
17+
name: Restore gem cache
1518
keys:
16-
- gem-cache-{{ checksum "Gemfile.lock" }}
19+
- v1-gem-cache-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
20+
- v1-gem-cache-{{ arch }}-{{ .Branch }}-
21+
- v1-gem-cache-{{ arch }}-
1722
- run:
1823
name: Install ruby dependencies
1924
command: |
@@ -24,8 +29,13 @@ jobs:
2429
- run:
2530
name: Install scss_lint
2631
command: gem install scss_lint
32+
# Drop stale gems left by a partial cache restore.
33+
- run:
34+
name: Clean stale gems
35+
command: bundle clean --force
2736
- save_cache:
28-
key: gem-cache-{{ checksum "Gemfile.lock" }}
37+
name: Save gem cache
38+
key: v1-gem-cache-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
2939
paths:
3040
- vendor/bundle
3141
- persist_to_workspace:
@@ -36,16 +46,22 @@ jobs:
3646
executor: my-executor
3747
steps:
3848
- checkout
49+
# Fallback keys allow partial cache hits when the lock file changes.
3950
- restore_cache:
51+
name: Restore npm cache
4052
keys:
41-
- npm-cache-{{ checksum "package-lock.json" }}
53+
- v1-node-{{ .Branch }}-{{ checksum "package-lock.json" }}
54+
- v1-node-{{ .Branch }}-
55+
- v1-node-
4256
- run:
4357
name: Install node dependencies
4458
command: npm ci
59+
# Cache npm's download store, not node_modules; npm ci rebuilds node_modules from it.
4560
- save_cache:
46-
key: npm-cache-{{ checksum "package-lock.json" }}
61+
name: Save npm cache
62+
key: v1-node-{{ .Branch }}-{{ checksum "package-lock.json" }}
4763
paths:
48-
- node_modules
64+
- ~/.npm
4965
- persist_to_workspace:
5066
root: ~/project
5167
paths:
@@ -54,15 +70,9 @@ jobs:
5470
executor: my-executor
5571
steps:
5672
- checkout
73+
# Deps come from the workspace; no cache restore needed here.
5774
- attach_workspace:
5875
at: ~/project
59-
- restore_cache:
60-
keys:
61-
- npm-cache-{{ checksum "package-lock.json" }}
62-
- restore_cache:
63-
keys:
64-
- gem-cache-{{ checksum "Gemfile.lock" }}
65-
- gem-cache
6676
- run:
6777
name: Build site assets
6878
command: npm run build:all-assets
@@ -79,7 +89,6 @@ jobs:
7989
- "node_modules"
8090
test_build:
8191
executor: my-executor
82-
parallelism: 2
8392
steps:
8493
- checkout
8594
- attach_workspace:
@@ -100,9 +109,20 @@ jobs:
100109
name: Link bundler
101110
command: |
102111
bundle config set --local path '~/project/vendor/bundle'
112+
# --skip-initial-build: `_site`/`assets` are already built and attached
113+
# from the `build` job's workspace, so this just serves them. Without
114+
# this flag, `jekyll serve` rebuilds from source on startup — which
115+
# each of the 2 parallel containers here would do redundantly.
116+
# The curl loop waits for --detach's background server to actually be
117+
# ready before pa11y-ci starts hitting it.
118+
# pa11y-ci:shard (not pa11y-ci:sitemap) is what makes parallelism: 2
119+
# above actually cut time to completion when using a sharded sitemap.
103120
- run:
104121
name: Run pa11y-ci desktop
105-
command: npm run start-detached && npm run pa11y-ci:sitemap
122+
command: |
123+
bundle exec jekyll serve --detach --host=localhost --skip-initial-build
124+
until curl -sf http://localhost:4000/sitemap.xml -o /dev/null; do sleep 1; done
125+
npm run pa11y-ci:shard
106126
test_a11y_mobile:
107127
executor: my-executor
108128
parallelism: 2
@@ -114,9 +134,13 @@ jobs:
114134
name: Link bundler
115135
command: |
116136
bundle config set --local path '~/project/vendor/bundle'
137+
# Same reasoning as test_a11y_desktop's "Run pa11y-ci desktop" step.
117138
- run:
118139
name: Run pa11y-ci mobile
119-
command: npm run start-detached && npm run pa11y-ci:sitemap-mobile
140+
command: |
141+
bundle exec jekyll serve --detach --host=localhost --skip-initial-build
142+
until curl -sf http://localhost:4000/sitemap.xml -o /dev/null; do sleep 1; done
143+
npm run pa11y-ci:shard-mobile
120144
121145
workflows:
122146
circle-uswds-site:

config/shard-sitemap.mjs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// test_a11y_desktop/test_a11y_mobile in .circleci/config.yml already run with
2+
// parallelism: 2, but before this script existed both containers ran the
3+
// exact same full sitemap through pa11y-ci — parallelism was configured but
4+
// actually scans of all URLs in duplicate. This script gives
5+
// each container a distinct slice of the sitemap so the parallelism is real.
6+
import fs from "node:fs";
7+
import path from "node:path";
8+
import http from "node:http";
9+
import https from "node:https";
10+
11+
// Same exclusion pa11y-ci:sitemap/-mobile already pass via
12+
// `--sitemap-exclude '/*.pdf|next/'`, kept in sync so sharded runs exclude
13+
// the same URLs as the unsharded ones.
14+
const EXCLUDE_PATTERN = new RegExp("/*.pdf|next/");
15+
16+
function parseArgs(argv) {
17+
const args = {};
18+
for (let i = 0; i < argv.length; i += 2) {
19+
const key = argv[i].replace(/^--/, "");
20+
args[key] = argv[i + 1];
21+
}
22+
return args;
23+
}
24+
25+
function fetchText(url) {
26+
const client = url.startsWith("https:") ? https : http;
27+
return new Promise((resolve, reject) => {
28+
client
29+
.get(url, (res) => {
30+
if (res.statusCode < 200 || res.statusCode >= 300) {
31+
reject(new Error(`Failed to fetch ${url}: HTTP ${res.statusCode}`));
32+
res.resume();
33+
return;
34+
}
35+
let body = "";
36+
res.setEncoding("utf8");
37+
res.on("data", (chunk) => (body += chunk));
38+
res.on("end", () => resolve(body));
39+
})
40+
.on("error", reject);
41+
});
42+
}
43+
44+
// The sitemap is fetched over the network, so its <loc> entries are
45+
// untrusted input: validate each one resolves to a well-formed http(s) URL
46+
// on the same origin as the sitemap itself before it's allowed anywhere near
47+
// the shard config that gets written to disk and fed to pa11y-ci. Jekyll's
48+
// sitemap emits root-relative paths (e.g. "/components/button/"), so each
49+
// entry is resolved against sitemapUrl as a base.
50+
function extractUrls(sitemapXml, sitemapUrl) {
51+
const allowedOrigin = new URL(sitemapUrl).origin;
52+
const urls = [];
53+
const locRegex = /<loc>(.*?)<\/loc>/g;
54+
let match;
55+
while ((match = locRegex.exec(sitemapXml)) !== null) {
56+
const raw = match[1].trim();
57+
let parsed;
58+
try {
59+
parsed = new URL(raw, sitemapUrl);
60+
} catch {
61+
console.warn(`Skipping malformed sitemap URL: ${raw}`);
62+
continue;
63+
}
64+
if (
65+
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
66+
parsed.origin !== allowedOrigin
67+
) {
68+
console.warn(`Skipping out-of-origin sitemap URL: ${raw}`);
69+
continue;
70+
}
71+
urls.push(parsed.href);
72+
}
73+
return urls;
74+
}
75+
76+
// Round-robin rather than contiguous slices: sitemap URLs cluster by
77+
// directory (components, templates, patterns, ...), so a contiguous split
78+
// would risk loading all the heavy pages onto one shard. Interleaving
79+
// spreads page types evenly across containers without needing per-page
80+
// timing data.
81+
function shardUrls(urls, index, total) {
82+
return urls.filter((_, i) => i % total === index);
83+
}
84+
85+
async function main() {
86+
const args = parseArgs(process.argv.slice(2));
87+
const sitemapUrl = args.sitemap || "http://localhost:4000/sitemap.xml";
88+
const baseConfigPath = args["base-config"] || ".pa11yci";
89+
const outPath = args.out;
90+
91+
if (!outPath) {
92+
throw new Error("--out <path> is required");
93+
}
94+
95+
// CircleCI sets these automatically from a job's `parallelism:` value.
96+
const index = parseInt(process.env.CIRCLE_NODE_INDEX || "0", 10);
97+
const total = parseInt(process.env.CIRCLE_NODE_TOTAL || "1", 10);
98+
99+
const sitemapXml = await fetchText(sitemapUrl);
100+
const allUrls = extractUrls(sitemapXml, sitemapUrl).filter(
101+
(url) => !EXCLUDE_PATTERN.test(url)
102+
);
103+
const shard = shardUrls(allUrls, index, total);
104+
105+
const baseConfig = JSON.parse(
106+
fs.readFileSync(path.resolve(baseConfigPath), "utf8")
107+
);
108+
109+
// Only `defaults` carries over from the base .pa11yci/.pa11yci--mobile —
110+
// `urls` here is this shard's slice, not whatever (if anything) was in
111+
// the base config.
112+
const shardConfig = {
113+
defaults: baseConfig.defaults,
114+
urls: shard,
115+
};
116+
117+
fs.writeFileSync(outPath, JSON.stringify(shardConfig, null, 2));
118+
119+
console.log(
120+
`Shard ${index + 1}/${total}: ${shard.length}/${allUrls.length} URLs ` +
121+
`(from ${sitemapUrl}) written to ${outPath}`
122+
);
123+
}
124+
125+
main().catch((err) => {
126+
console.error(err);
127+
process.exit(1);
128+
});

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@
7070
"pa11y-ci:sitemap": "pa11y-ci --sitemap http://localhost:4000/sitemap.xml --sitemap-exclude '/*.pdf|next/'",
7171
"pa11y-ci:sitemap-mobile": "pa11y-ci --config .pa11yci--mobile --sitemap http://localhost:4000/sitemap.xml --sitemap-exclude '/*.pdf|next/'",
7272
"pa11y-ci:sitemap-json": "pa11y-ci --json > pa11y-results.json --sitemap http://localhost:4000/sitemap.xml --sitemap-exclude '/*.pdf|next/'",
73+
"pa11y-ci:shard": "node config/shard-sitemap.mjs --base-config .pa11yci --out /tmp/pa11yci-shard.json && pa11y-ci --config /tmp/pa11yci-shard.json",
74+
"pa11y-ci:shard-mobile": "node config/shard-sitemap.mjs --base-config .pa11yci--mobile --out /tmp/pa11yci-shard-mobile.json && pa11y-ci --config /tmp/pa11yci-shard-mobile.json",
7375
"prettier:scss": "npx prettier --write './css/**/*.scss'"
7476
},
7577
"devDependencies": {

0 commit comments

Comments
 (0)