deeplink-parity 0.3.1 → 0.5.0

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/README.md CHANGED
@@ -97,7 +97,47 @@ npx deeplink-parity . --sha256 "AB:CD:…" # include the fingerpri
97
97
  The Android signing fingerprint comes from Play Console → App integrity → App signing.
98
98
  Without it the fingerprint check is skipped and reported as such.
99
99
 
100
- Exits `1` when there is at least one error, so it drops into CI unchanged.
100
+ ### GitHub Action
101
+
102
+ ```yaml
103
+ - uses: camosss/deeplink-parity@v1
104
+ with:
105
+ paths: ios android # one per checkout; omit for a single repo
106
+ sha256: ${{ secrets.ANDROID_SHA256 }}
107
+ ```
108
+
109
+ Findings appear as annotations on the run, and counts are available to later steps:
110
+
111
+ ```yaml
112
+ - uses: camosss/deeplink-parity@v1
113
+ id: links
114
+ with:
115
+ fail-on: never # report without blocking while a backlog is cleared
116
+ - run: echo "${{ steps.links.outputs.errors }} errors, ${{ steps.links.outputs.warnings }} warnings"
117
+ ```
118
+
119
+ | Input | Default | |
120
+ |---|---|---|
121
+ | `paths` | `.` | Checkout paths, whitespace separated |
122
+ | `sha256` | — | Android signing fingerprint |
123
+ | `well-known` | — | Read from disk instead of the network |
124
+ | `fail-on` | `error` | `never` to report without failing the step |
125
+ | `version` | pinned | npm version to run |
126
+
127
+ Outputs: `errors`, `warnings`, `notices`, `domains`, `report` (path to the JSON result).
128
+
129
+ ### In CI
130
+
131
+ | Exit code | Meaning |
132
+ |---|---|
133
+ | `0` | No errors. Warnings and notices do not fail the run |
134
+ | `1` | At least one error — a link is broken today |
135
+ | `2` | Nothing to check, or the run itself failed |
136
+
137
+ Colour is emitted only to a terminal, so piped and captured output stays plain. `--json`
138
+ writes nothing but JSON to stdout; progress notes go to stderr. On GitHub Actions the
139
+ findings are also emitted as annotations, which appear on the run summary and against the
140
+ file when one is involved — set `--format github` to force it elsewhere.
101
141
 
102
142
  ### Run it on a schedule — this is the point
103
143
 
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { writeFile } from 'node:fs/promises';
2
3
  import { resolve } from 'node:path';
3
4
  import { localSource, networkSource } from './fetch/wellKnown.js';
4
5
  import { exitCodeFor, printReport } from './report/console.js';
6
+ import { printGithubAnnotations } from './report/github.js';
5
7
  import { run } from './run.js';
6
8
  const USAGE = `deeplink-parity — check that what your app declares about deep links
7
9
  matches what is actually hosted, across iOS and Android.
@@ -17,7 +19,9 @@ Usage
17
19
  Options
18
20
  --sha256 <fingerprint> Android signing fingerprint to look for in assetlinks.json
19
21
  --well-known <dir> Read well-known files from <dir>/<domain>/ instead of the network
20
- --json Machine-readable output
22
+ --json Machine-readable output on stdout
23
+ --output <file> Also write the JSON result to a file
24
+ --format github GitHub Actions annotations (auto-detected on Actions)
21
25
  -h, --help Show this message
22
26
  `;
23
27
  function parseArgs(argv) {
@@ -27,6 +31,9 @@ function parseArgs(argv) {
27
31
  let help = false;
28
32
  let sha256;
29
33
  let wellKnown;
34
+ let output;
35
+ // Actions sets GITHUB_ACTIONS=true; annotate by default there
36
+ let format = process.env.GITHUB_ACTIONS === 'true' ? 'github' : 'console';
30
37
  for (let i = 0; i < args.length; i++) {
31
38
  const arg = args[i];
32
39
  if (arg === '--json')
@@ -37,6 +44,10 @@ function parseArgs(argv) {
37
44
  sha256 = args[++i];
38
45
  else if (arg === '--well-known')
39
46
  wellKnown = args[++i];
47
+ else if (arg === '--format')
48
+ format = args[++i];
49
+ else if (arg === '--output')
50
+ output = args[++i];
40
51
  else if (!arg.startsWith('-'))
41
52
  roots.push(arg);
42
53
  }
@@ -46,10 +57,12 @@ function parseArgs(argv) {
46
57
  help,
47
58
  sha256,
48
59
  wellKnown,
60
+ format,
61
+ output,
49
62
  };
50
63
  }
51
64
  async function main() {
52
- const { roots, json, help, sha256, wellKnown } = parseArgs(process.argv);
65
+ const { roots, json, help, sha256, wellKnown, format, output } = parseArgs(process.argv);
53
66
  if (help) {
54
67
  console.log(USAGE);
55
68
  return;
@@ -71,23 +84,36 @@ async function main() {
71
84
  console.error('Expected a .entitlements file with applinks:, or an AndroidManifest.xml with intent-filters.');
72
85
  process.exit(2);
73
86
  }
87
+ const payload = {
88
+ ios: result.iosApps.map((a) => ({
89
+ entitlements: a.entitlementsPath,
90
+ teamId: a.teamId,
91
+ bundleId: a.bundleId,
92
+ domains: a.domains,
93
+ })),
94
+ android: result.androidApps.map((a) => ({
95
+ manifest: a.manifestPath,
96
+ packageIds: a.packageIds,
97
+ hosts: a.hosts.map((h) => h.host),
98
+ })),
99
+ summary: {
100
+ domains: result.domains.length,
101
+ error: result.findings.filter((f) => f.severity === 'error').length,
102
+ warn: result.findings.filter((f) => f.severity === 'warn').length,
103
+ info: result.findings.filter((f) => f.severity === 'info').length,
104
+ },
105
+ findings: result.findings,
106
+ };
107
+ // A file keeps stdout free, so annotations and the readable report can coexist with
108
+ // machine-readable output in the same run.
109
+ if (output)
110
+ await writeFile(output, `${JSON.stringify(payload, null, 2)}\n`);
74
111
  if (json) {
75
- console.log(JSON.stringify({
76
- ios: result.iosApps.map((a) => ({
77
- entitlements: a.entitlementsPath,
78
- teamId: a.teamId,
79
- bundleId: a.bundleId,
80
- domains: a.domains,
81
- })),
82
- android: result.androidApps.map((a) => ({
83
- manifest: a.manifestPath,
84
- packageIds: a.packageIds,
85
- hosts: a.hosts.map((h) => h.host),
86
- })),
87
- findings: result.findings,
88
- }, null, 2));
112
+ console.log(JSON.stringify(payload, null, 2));
89
113
  }
90
114
  else {
115
+ if (format === 'github')
116
+ printGithubAnnotations(result.findings);
91
117
  printReport(result.findings, result.domains);
92
118
  }
93
119
  process.exit(exitCodeFor(result.findings));
@@ -11,7 +11,9 @@ const ORDER = ['error', 'warn', 'info'];
11
11
  const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
12
12
  const paint = (s, c) => (useColor ? `${c}${s}${RESET}` : s);
13
13
  export function printReport(findings, checkedDomains) {
14
- console.log(`\n${paint('deeplink-parity', BOLD)} ${DIM}·${RESET} ${checkedDomains.length} domain(s) checked\n`);
14
+ // every escape goes through paint(), which is a no-op when stdout is not a terminal
15
+ const separator = paint('·', DIM);
16
+ console.log(`\n${paint('deeplink-parity', BOLD)} ${separator} ${checkedDomains.length} domain(s) checked\n`);
15
17
  if (findings.length === 0) {
16
18
  console.log(paint('No problems found', ''));
17
19
  console.log();
@@ -0,0 +1,31 @@
1
+ import { isAbsolute } from 'node:path';
2
+ const LEVEL = {
3
+ error: 'error',
4
+ warn: 'warning',
5
+ info: 'notice',
6
+ };
7
+ /** Workflow commands treat these characters as syntax and need them escaped. */
8
+ function escapeData(value) {
9
+ return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
10
+ }
11
+ function escapeProperty(value) {
12
+ return escapeData(value).replace(/:/g, '%3A').replace(/,/g, '%2C');
13
+ }
14
+ /**
15
+ * GitHub Actions annotation format, so findings appear on the run summary — and on the
16
+ * changed lines when a finding points at a file in the repository.
17
+ * https://docs.github.com/actions/reference/workflow-commands-for-github-actions
18
+ */
19
+ export function printGithubAnnotations(findings) {
20
+ for (const f of findings) {
21
+ const props = [`title=${escapeProperty(`deeplink-parity ${f.rule}`)}`];
22
+ // Annotations anchor to a path inside the checkout. A source can also be a URL or an
23
+ // absolute path outside it — neither anchors to anything, so the annotation stays
24
+ // on the run summary instead of pointing at a file that will not resolve.
25
+ if (f.source && !/^https?:\/\//.test(f.source) && !isAbsolute(f.source)) {
26
+ props.push(`file=${escapeProperty(f.source)}`);
27
+ }
28
+ const body = [f.domain, f.message, f.detail].filter(Boolean).join(' — ');
29
+ console.log(`::${LEVEL[f.severity]} ${props.join(',')}::${escapeData(body)}`);
30
+ }
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deeplink-parity",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Checks that what your mobile app declares about deep links matches what is actually hosted — across iOS and Android.",
5
5
  "type": "module",
6
6
  "bin": {