@lhechenberger/check-links 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/package.json +45 -4
- package/src/classify.ts +45 -0
- package/src/cli.ts +52 -0
- package/src/config.ts +33 -0
- package/src/index.ts +3 -0
- package/src/run.ts +84 -0
- package/templates/broken-links.yml +116 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lukas Hechenberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lhechenberger/check-links
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Crawls a deployed site for broken links and tells real failures apart from flaky ones. Made for CI checks against Vercel preview deployments, built on [linkinator](https://github.com/JustinBeckwith/linkinator).
|
|
4
|
+
|
|
5
|
+
Fresh deployments answer some first requests with errors: image optimization (`/_next/image?url=…`) from a function that has just started often returns `400`, hundreds of URLs at once. A plain crawl fails on these. This tool:
|
|
6
|
+
|
|
7
|
+
1. crawls the site (with linkinator's own retries for 5xx and network errors),
|
|
8
|
+
2. waits a few seconds,
|
|
9
|
+
3. requests every broken link **again, at low concurrency**,
|
|
10
|
+
4. reports links that work now as **warnings** ("flaky") and only fails (exit code `1`) for links that are still broken.
|
|
11
|
+
|
|
12
|
+
It also handles Vercel's deployment protection (bypass header, and a check that the start URL isn't the login page) and can downgrade known-unfixable links (e.g. third-party content) to warnings.
|
|
13
|
+
|
|
14
|
+
Requires [Bun](https://bun.sh) (the package ships TypeScript source and runs with a Bun shebang).
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun add -d @lhechenberger/check-links
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bunx check-links https://my-preview.example.vercel.app
|
|
26
|
+
bunx check-links # uses `url` from the config, e.g. the local dev server
|
|
27
|
+
bunx check-links <url> --config ci/check-links.config.ts
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Set `VERCEL_AUTOMATION_BYPASS_SECRET` (Vercel project → Deployment Protection → Protection Bypass for Automation) to check protected deployments. The secret is sent as `x-vercel-protection-bypass` header, also to the second requests (same-origin only, never to third parties).
|
|
31
|
+
|
|
32
|
+
The last line of the output is a one-line summary (`Checked N links on <origin>: X broken, Y warnings`); the exit code is `1` if `X > 0`.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
Optional `check-links.config.ts` in the working directory:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { defineConfig } from '@lhechenberger/check-links';
|
|
40
|
+
|
|
41
|
+
export default defineConfig({
|
|
42
|
+
url: 'http://localhost:3000',
|
|
43
|
+
// Never checked. Strings are regular expression sources.
|
|
44
|
+
skip: [/^https:\/\/www\.instagram\.com\//, String.raw`^https://example\.org/`],
|
|
45
|
+
// Broken links that must not fail the check, e.g. content you can't fix. Not re-requested.
|
|
46
|
+
isWarningOnly: (link, { origin, url }) =>
|
|
47
|
+
!link.url.startsWith(origin) && new URL(link.parent ?? url).pathname.startsWith('/docs/'),
|
|
48
|
+
warningTitle: 'Broken links in embedded READMEs',
|
|
49
|
+
retryAfterMs: 5000, // wait before the second request
|
|
50
|
+
recheckConcurrency: 5, // parallel second requests
|
|
51
|
+
concurrency: 20, // parallel requests of the crawl
|
|
52
|
+
timeout: 30_000,
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Option | Default | Description |
|
|
57
|
+
| -------------------- | ------- | ---------------------------------------------------- |
|
|
58
|
+
| `url` | – | Used when no URL is passed on the command line |
|
|
59
|
+
| `skip` | `[]` | Links not to check |
|
|
60
|
+
| `isWarningOnly` | – | `(link, { origin, url }) => boolean` |
|
|
61
|
+
| `warningTitle` | generic | Heading for the `isWarningOnly` links |
|
|
62
|
+
| `retryAfterMs` | `5000` | Wait before broken links are requested a second time |
|
|
63
|
+
| `recheckConcurrency` | `5` | Parallel second requests; keep low (see above) |
|
|
64
|
+
| `concurrency` | `20` | Parallel requests of the crawl |
|
|
65
|
+
| `timeout` | `30000` | Timeout per request in ms |
|
|
66
|
+
|
|
67
|
+
## GitHub workflow: check every Vercel deployment
|
|
68
|
+
|
|
69
|
+
The package contains a workflow template that runs the check when Vercel reports a ready deployment, shows the result as a **"Broken Links" check run** on the deployed commit (so it appears on the pull request, with the full output) and reports the commit status that Vercel's Deployment Checks can require before a production deployment gets its domain.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bunx check-links init-workflow # writes .github/workflows/broken-links.yml
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Setup:
|
|
76
|
+
|
|
77
|
+
1. Add the repository secret `VERCEL_AUTOMATION_BYPASS_SECRET` (same value as in the Vercel project's protection bypass).
|
|
78
|
+
2. In Vercel, enable Deployment Checks / the `repository_dispatch` events for the project, so it sends `vercel.deployment.ready`.
|
|
79
|
+
3. Commit the workflow to the **default branch** (GitHub runs `repository_dispatch` workflows only from there).
|
|
80
|
+
4. Adjust the "Check for broken links" step if the package isn't a dependency of the repository root (e.g. `bun --cwd=apps/web run …`), and optionally remove the "Report to Vercel" step.
|
|
81
|
+
5. Run it once by hand: Actions → Broken Links → Run workflow, with a deployment URL.
|
|
82
|
+
|
|
83
|
+
Without the secret, production deployments fail the job (they would be held back anyway) and previews are skipped with a warning.
|
|
84
|
+
|
|
85
|
+
## Local development of the checks
|
|
86
|
+
|
|
87
|
+
`bun test` runs the unit tests of the classification (flaky vs. real errors).
|
|
88
|
+
|
|
89
|
+
## Programmatic API
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { classify, run, defineConfig } from '@lhechenberger/check-links';
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`run(url, config)` crawls and prints, and resolves to the exit code; `classify` is the pure part that sorts broken links into `errors`, `flaky` and `warnings`.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lhechenberger/check-links",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Crawls a deployed site for broken links, separating flaky failures from real ones. Built for Vercel preview deployments.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"broken-links",
|
|
7
|
+
"link-checker",
|
|
8
|
+
"linkinator",
|
|
9
|
+
"vercel",
|
|
10
|
+
"ci"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/LukasHechenberger/workbench/tree/main/packages/check-links#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/LukasHechenberger/workbench/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/LukasHechenberger/workbench.git",
|
|
19
|
+
"directory": "packages/check-links"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "Lukas Hechenberger",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"check-links": "src/cli.ts"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"!src/**/*.test.ts",
|
|
33
|
+
"templates"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "bun test"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"linkinator": "^8.1.0"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"bun": ">=1.2"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/classify.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { LinkResult } from 'linkinator';
|
|
2
|
+
|
|
3
|
+
export interface Classification {
|
|
4
|
+
/** Still broken on the second request */
|
|
5
|
+
errors: LinkResult[];
|
|
6
|
+
/** Worked on the second request */
|
|
7
|
+
flaky: LinkResult[];
|
|
8
|
+
/** Broken, but allowed to be (see `isWarningOnly`) */
|
|
9
|
+
warnings: LinkResult[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ClassifyOptions {
|
|
13
|
+
isWarningOnly: (link: LinkResult) => boolean;
|
|
14
|
+
/** Requests `link` again and tells whether it works now */
|
|
15
|
+
recovers: (link: LinkResult) => Promise<boolean>;
|
|
16
|
+
/** Waits before the second pass; only called if there is something to re-request */
|
|
17
|
+
wait: () => Promise<void>;
|
|
18
|
+
/** Parallel `recovers` calls */
|
|
19
|
+
concurrency: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sorts broken links into real errors, flaky links (fine on a second request) and tolerated
|
|
24
|
+
* warnings. Fresh deployments answer some first requests with errors, e.g. image optimizations
|
|
25
|
+
* from a function that has just started, so a failure on the first request isn't proof.
|
|
26
|
+
*/
|
|
27
|
+
export async function classify(
|
|
28
|
+
broken: LinkResult[],
|
|
29
|
+
{ isWarningOnly, recovers, wait, concurrency }: ClassifyOptions,
|
|
30
|
+
): Promise<Classification> {
|
|
31
|
+
const warnings = broken.filter((link) => isWarningOnly(link));
|
|
32
|
+
const toRecheck = broken.filter((link) => !isWarningOnly(link));
|
|
33
|
+
const errors: LinkResult[] = [];
|
|
34
|
+
const flaky: LinkResult[] = [];
|
|
35
|
+
if (toRecheck.length === 0) return { errors, flaky, warnings };
|
|
36
|
+
|
|
37
|
+
await wait();
|
|
38
|
+
// A burst of requests is what failed in the first place, so the second pass is gentle
|
|
39
|
+
for (let i = 0; i < toRecheck.length; i += concurrency) {
|
|
40
|
+
const batch = toRecheck.slice(i, i + concurrency);
|
|
41
|
+
const results = await Promise.all(batch.map((link) => recovers(link)));
|
|
42
|
+
for (const [index, link] of batch.entries()) (results[index] ? flaky : errors).push(link);
|
|
43
|
+
}
|
|
44
|
+
return { errors, flaky, warnings };
|
|
45
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import type { CheckLinksConfig } from './config.ts';
|
|
6
|
+
import { run } from './run.ts';
|
|
7
|
+
|
|
8
|
+
const usage = `Usage:
|
|
9
|
+
check-links [url] [--config check-links.config.ts]
|
|
10
|
+
check-links init-workflow [path] Writes the GitHub workflow template (default .github/workflows/broken-links.yml)`;
|
|
11
|
+
|
|
12
|
+
const { positionals, values } = parseArgs({
|
|
13
|
+
allowPositionals: true,
|
|
14
|
+
options: { config: { type: 'string' }, help: { type: 'boolean', short: 'h' } },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (values.help) {
|
|
18
|
+
console.log(usage);
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (positionals[0] === 'init-workflow') {
|
|
23
|
+
const target = resolve(positionals[1] ?? '.github/workflows/broken-links.yml');
|
|
24
|
+
if (existsSync(target)) {
|
|
25
|
+
console.error(`${target} exists already`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
29
|
+
writeFileSync(
|
|
30
|
+
target,
|
|
31
|
+
readFileSync(new URL('../templates/broken-links.yml', import.meta.url), 'utf8'),
|
|
32
|
+
);
|
|
33
|
+
console.log(`Wrote ${target}. See the README for the setup it needs.`);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const configPath = resolve(values.config ?? 'check-links.config.ts');
|
|
38
|
+
let config: CheckLinksConfig = {};
|
|
39
|
+
if (existsSync(configPath)) {
|
|
40
|
+
config = ((await import(configPath)) as { default: CheckLinksConfig }).default;
|
|
41
|
+
} else if (values.config) {
|
|
42
|
+
console.error(`Config ${configPath} not found`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const url = positionals[0] ?? config.url;
|
|
47
|
+
if (!url) {
|
|
48
|
+
console.error(`No URL given.\n\n${usage}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
process.exit(await run(url, config));
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { LinkResult } from 'linkinator';
|
|
2
|
+
|
|
3
|
+
export interface LinkContext {
|
|
4
|
+
/** Origin of the checked site, e.g. `https://preview.example.com` */
|
|
5
|
+
origin: string;
|
|
6
|
+
/** The URL the crawl started at */
|
|
7
|
+
url: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CheckLinksConfig {
|
|
11
|
+
/** URL to check when none is passed on the command line (e.g. the local dev server) */
|
|
12
|
+
url?: string;
|
|
13
|
+
/** Links not to check. Strings are matched as regular expression sources. */
|
|
14
|
+
skip?: (string | RegExp)[];
|
|
15
|
+
/**
|
|
16
|
+
* Broken links that only count as warnings (e.g. content you don't control). They are not
|
|
17
|
+
* requested a second time.
|
|
18
|
+
*/
|
|
19
|
+
isWarningOnly?: (link: LinkResult, context: LinkContext) => boolean;
|
|
20
|
+
/** Heading printed above the `isWarningOnly` links */
|
|
21
|
+
warningTitle?: string;
|
|
22
|
+
/** Wait before broken links are requested a second time (ms), default 5000 */
|
|
23
|
+
retryAfterMs?: number;
|
|
24
|
+
/** Parallel requests of the second pass, default 5. Low on purpose: see the README. */
|
|
25
|
+
recheckConcurrency?: number;
|
|
26
|
+
/** Parallel requests of the crawl, default 20 */
|
|
27
|
+
concurrency?: number;
|
|
28
|
+
/** Timeout per request (ms), default 30000 */
|
|
29
|
+
timeout?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Type helper for `check-links.config.ts` */
|
|
33
|
+
export const defineConfig = (config: CheckLinksConfig): CheckLinksConfig => config;
|
package/src/index.ts
ADDED
package/src/run.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { check, type LinkResult, LinkState } from 'linkinator';
|
|
2
|
+
import { classify } from './classify.ts';
|
|
3
|
+
import type { CheckLinksConfig } from './config.ts';
|
|
4
|
+
|
|
5
|
+
const BYPASS_ENV = 'VERCEL_AUTOMATION_BYPASS_SECRET';
|
|
6
|
+
|
|
7
|
+
/** Crawls `url` and prints the result. Returns the process exit code. */
|
|
8
|
+
export async function run(url: string, config: CheckLinksConfig): Promise<number> {
|
|
9
|
+
const { origin } = new URL(url);
|
|
10
|
+
const bypassSecret = process.env[BYPASS_ENV];
|
|
11
|
+
// Lets the crawler through Vercel's deployment protection
|
|
12
|
+
const headers: Record<string, string> = bypassSecret
|
|
13
|
+
? { 'x-vercel-protection-bypass': bypassSecret }
|
|
14
|
+
: {};
|
|
15
|
+
|
|
16
|
+
// A protected deployment redirects to Vercel's login page, which linkinator would happily
|
|
17
|
+
// check instead of the site
|
|
18
|
+
const start = await fetch(url, { headers, redirect: 'manual' });
|
|
19
|
+
if (!start.ok) {
|
|
20
|
+
console.error(
|
|
21
|
+
`${url} answered with ${start.status} ${start.headers.get('location') ?? ''}`.trim() +
|
|
22
|
+
(bypassSecret ? '' : `\nIf the deployment is protected, set ${BYPASS_ENV}.`),
|
|
23
|
+
);
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const skip = (config.skip ?? []).map((pattern) =>
|
|
28
|
+
typeof pattern === 'string' ? new RegExp(pattern) : pattern,
|
|
29
|
+
);
|
|
30
|
+
const { links } = await check({
|
|
31
|
+
path: url,
|
|
32
|
+
recurse: true,
|
|
33
|
+
concurrency: config.concurrency ?? 20,
|
|
34
|
+
timeout: config.timeout ?? 30_000,
|
|
35
|
+
// Retries 5xx responses and network errors, which are mostly temporary
|
|
36
|
+
retryErrors: true,
|
|
37
|
+
retryErrorsCount: 2,
|
|
38
|
+
retry: true,
|
|
39
|
+
headers,
|
|
40
|
+
linksToSkip: async (link) => skip.some((pattern) => pattern.test(link)),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const isWarningOnly = (link: LinkResult) =>
|
|
44
|
+
config.isWarningOnly?.(link, { origin, url }) ?? false;
|
|
45
|
+
|
|
46
|
+
/** Whether `link` works when requested again, a while after the crawl */
|
|
47
|
+
const recovers = async (link: LinkResult) => {
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(link.url, {
|
|
50
|
+
headers: link.url.startsWith(origin) ? headers : {},
|
|
51
|
+
});
|
|
52
|
+
await response.body?.cancel();
|
|
53
|
+
return response.ok;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const broken = links.filter((link) => link.state === LinkState.BROKEN);
|
|
60
|
+
const { errors, flaky, warnings } = await classify(broken, {
|
|
61
|
+
isWarningOnly,
|
|
62
|
+
recovers,
|
|
63
|
+
wait: () => new Promise((resolve) => setTimeout(resolve, config.retryAfterMs ?? 5000)),
|
|
64
|
+
concurrency: config.recheckConcurrency ?? 5,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const print = (title: string, results: LinkResult[]) => {
|
|
68
|
+
if (results.length === 0) return;
|
|
69
|
+
console.log(`\n${title}`);
|
|
70
|
+
for (const link of results) {
|
|
71
|
+
console.log(` [${link.status ?? 'error'}] ${link.url}\n on ${link.parent}`);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
print(`${config.warningTitle ?? 'Broken links that are only warnings'} (warnings):`, warnings);
|
|
76
|
+
print('Links that worked on a second request (warnings):', flaky);
|
|
77
|
+
print('Broken links:', errors);
|
|
78
|
+
// The last line is the summary the workflow shows as the check run's title
|
|
79
|
+
console.log(
|
|
80
|
+
`\nChecked ${links.length} links on ${origin}: ${errors.length} broken, ${warnings.length + flaky.length} warnings`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return errors.length > 0 ? 1 : 0;
|
|
84
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
name: Broken Links
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
# Sent by Vercel once a deployment is ready. Runs the workflow file on the default branch.
|
|
5
|
+
repository_dispatch:
|
|
6
|
+
types:
|
|
7
|
+
- 'vercel.deployment.ready'
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
inputs:
|
|
10
|
+
url:
|
|
11
|
+
type: string
|
|
12
|
+
description: The URL of the deployed site to check
|
|
13
|
+
required: true
|
|
14
|
+
|
|
15
|
+
permissions:
|
|
16
|
+
contents: read
|
|
17
|
+
checks: write
|
|
18
|
+
statuses: write
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
broken-links:
|
|
22
|
+
# Differs from the check run's name below, so the two don't collide
|
|
23
|
+
name: Crawl deployment
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
timeout-minutes: 15
|
|
26
|
+
env:
|
|
27
|
+
URL: ${{ github.event.client_payload.url || inputs.url }}
|
|
28
|
+
SHA: ${{ github.event.client_payload.git.sha || github.sha }}
|
|
29
|
+
ENVIRONMENT: ${{ github.event.client_payload.environment || 'manual' }}
|
|
30
|
+
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
|
|
31
|
+
steps:
|
|
32
|
+
# Sets the commit status "Broken Links | broken-links (<project> - <environment>)" that the
|
|
33
|
+
# project's Deployment Checks can require before production deployments get the domain:
|
|
34
|
+
# pending now, and failed or passed once the job is done. Remove if you don't use them.
|
|
35
|
+
- name: Report to Vercel
|
|
36
|
+
uses: vercel/repository-dispatch/actions/status@v1
|
|
37
|
+
|
|
38
|
+
# Vercel sends the deployment's own URL, which is protected for production deployments too.
|
|
39
|
+
# Without the secret, production fails and previews are skipped.
|
|
40
|
+
- name: Require a bypass secret for deployments
|
|
41
|
+
id: guard
|
|
42
|
+
run: |
|
|
43
|
+
echo "Checking $URL ($ENVIRONMENT) at $SHA"
|
|
44
|
+
if [ "${{ github.event_name }}" = "repository_dispatch" ] && [ -z "$VERCEL_AUTOMATION_BYPASS_SECRET" ]; then
|
|
45
|
+
if [ "$ENVIRONMENT" = "production" ]; then
|
|
46
|
+
echo "::error::Set the VERCEL_AUTOMATION_BYPASS_SECRET secret to check production deployments"
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
echo "::warning::Set the VERCEL_AUTOMATION_BYPASS_SECRET secret to check preview deployments"
|
|
50
|
+
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
- name: Check out code
|
|
54
|
+
if: steps.guard.outputs.skip != 'true'
|
|
55
|
+
uses: actions/checkout@v5
|
|
56
|
+
with:
|
|
57
|
+
ref: ${{ env.SHA }}
|
|
58
|
+
|
|
59
|
+
- name: Setup Bun
|
|
60
|
+
if: steps.guard.outputs.skip != 'true'
|
|
61
|
+
uses: oven-sh/setup-bun@v2
|
|
62
|
+
|
|
63
|
+
- name: Install dependencies
|
|
64
|
+
if: steps.guard.outputs.skip != 'true'
|
|
65
|
+
run: bun install --frozen-lockfile
|
|
66
|
+
|
|
67
|
+
# Shows up next to the other checks of the deployed commit, also on its pull request
|
|
68
|
+
- name: Create check run
|
|
69
|
+
if: steps.guard.outputs.skip != 'true'
|
|
70
|
+
id: check_run
|
|
71
|
+
uses: actions/github-script@v8
|
|
72
|
+
with:
|
|
73
|
+
script: |
|
|
74
|
+
const { data } = await github.rest.checks.create({
|
|
75
|
+
...context.repo,
|
|
76
|
+
name: 'Broken Links',
|
|
77
|
+
head_sha: process.env.SHA,
|
|
78
|
+
status: 'in_progress',
|
|
79
|
+
details_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
|
80
|
+
});
|
|
81
|
+
return data.id;
|
|
82
|
+
|
|
83
|
+
- name: Check for broken links
|
|
84
|
+
if: steps.guard.outputs.skip != 'true'
|
|
85
|
+
# Adjust to where @lhechenberger/check-links is installed (or add a package script)
|
|
86
|
+
run: |
|
|
87
|
+
set -o pipefail
|
|
88
|
+
bunx check-links "$URL" | tee broken-links.txt
|
|
89
|
+
|
|
90
|
+
- name: Complete check run
|
|
91
|
+
if: ${{ !cancelled() && steps.check_run.outputs.result }}
|
|
92
|
+
uses: actions/github-script@v8
|
|
93
|
+
env:
|
|
94
|
+
CHECK_RUN_ID: ${{ steps.check_run.outputs.result }}
|
|
95
|
+
OUTCOME: ${{ job.status }}
|
|
96
|
+
with:
|
|
97
|
+
script: |
|
|
98
|
+
const fs = require('node:fs');
|
|
99
|
+
const output = fs.existsSync('broken-links.txt')
|
|
100
|
+
? fs.readFileSync('broken-links.txt', 'utf8')
|
|
101
|
+
: 'The check did not run.';
|
|
102
|
+
const conclusion = process.env.OUTCOME === 'success' ? 'success' : 'failure';
|
|
103
|
+
const summary = output.trim().split('\n').at(-1);
|
|
104
|
+
|
|
105
|
+
await github.rest.checks.update({
|
|
106
|
+
...context.repo,
|
|
107
|
+
check_run_id: Number(process.env.CHECK_RUN_ID),
|
|
108
|
+
status: 'completed',
|
|
109
|
+
conclusion,
|
|
110
|
+
output: {
|
|
111
|
+
title: summary,
|
|
112
|
+
summary: `${process.env.URL} (${process.env.ENVIRONMENT})`,
|
|
113
|
+
// Check run texts are limited to 65,535 characters
|
|
114
|
+
text: '```\n' + output.slice(-60_000) + '\n```',
|
|
115
|
+
},
|
|
116
|
+
});
|