sync-header-metadata 2.3.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 ADDED
@@ -0,0 +1,263 @@
1
+ <!-- vim:set expandtab shiftwidth=4 filetype=markdown foldlevel=3: -->
2
+ <!-- SPDX-License-Identifier: GPL-3.0-only -->
3
+
4
+ <!--
5
+ -
6
+ - ~chewygumxx/sync-header-metadata.git
7
+ - ::: :/README.md
8
+ -
9
+ -->
10
+
11
+ <!--
12
+ - Checks/rewrites the "~owner/repo.git" and
13
+ - "::: :/<path>" lines in tracked files' header banners against each
14
+ - file's actual repository and path.
15
+ -->
16
+
17
+ # sync-header-metadata
18
+
19
+ A GitHub Action (and local CLI) that keeps a file-header convention honest.
20
+
21
+ Checks/rewrites the `~owner/repo.git` and `::: :/<path>` lines in tracked files'
22
+ header banners against each file's actual repository and path.
23
+
24
+ ## Purpose
25
+
26
+ Consider the following textfile header, compliant in accordance with the
27
+ formalised format standard of a given repository:
28
+
29
+ ```js
30
+ #!/usr/bin/env node
31
+ // vim:set expandtab shiftwidth=4 filetype=javascript:
32
+
33
+ //
34
+ //
35
+ // ~owner/repo.git
36
+ // ::: :/path/to/this/file
37
+ //
38
+ //
39
+ ```
40
+
41
+ Were a file with this header to be renamed, moved, forked, or otherwise
42
+ displaced, those two lines quietly go stale. This action finds every tracked
43
+ file whose banner has drifted from its current repository and/or path and either
44
+ loudly fails (`verify` mode), or rewrites it (`update` mode). The logic is
45
+ commentstring invariant and will perform irrespective of surrounding language
46
+ syntax eg. `-- %s`, `// %s`, `# %s`, `; %s`, et cetera.
47
+
48
+ Both markers must be the last non-whitespace content on their line, so this
49
+ matches the multi-line comment-block style shown above (marker on its own
50
+ line, closer on a separate line), but not a single-line closed comment like
51
+ `<!-- ~owner/repo.git -->` or `/* ~owner/repo.git */`.
52
+
53
+ ## Usage
54
+
55
+ To verify without rewrite, failing on drift.
56
+
57
+ ```yaml
58
+ - uses: actions/checkout@v7
59
+
60
+ - name: Verify header repository and path
61
+ uses: chewygumxx/sync-header-metadata@v2
62
+ ```
63
+
64
+ To rewrite and update files instead of failing on desync:
65
+
66
+ ```yaml
67
+ - uses: actions/checkout@v7
68
+ with:
69
+ ref: ${{ github.head_ref || github.ref_name }}
70
+ persist-credentials: true
71
+
72
+ - name: Update tracked files
73
+ uses: chewygumxx/sync-header-metadata@v2
74
+ with:
75
+ mode: update
76
+
77
+ - name: Commit changes
78
+ uses: stefanzweifel/git-auto-commit-action@v7
79
+ with:
80
+ commit_message: "chore: Sync header metadata"
81
+ ```
82
+
83
+ For a complete, worked example, see the reusable
84
+ [`sync-header-metadata.yaml`](https://github.com/chewygumxx/.github/blob/v1/.github/workflows/sync-header-metadata.yaml)
85
+ workflow in `chewygumxx/.github`, which this repository's CI calls.
86
+
87
+ ### Inputs
88
+
89
+ | Input | Required | Default | Description |
90
+ | ------------ | -------- | --------- | ----------------------------------------------------------------- |
91
+ | `mode` | No | `verify` | `verify` exits non-zero on drift; `update` rewrites in place. |
92
+ | `verbose` | No | `false` | Enable INFO-level logging. |
93
+ | `annotation` | No | `false` | Emit `::notice::`/`::warning::`/`::error::` workflow annotations. |
94
+
95
+ ### Local usage
96
+
97
+ The same checks run from any local checkout via the
98
+ [`sync-header-metadata`](https://www.npmjs.com/package/sync-header-metadata)
99
+ npm package. Requires Node.js 24+ and `git` on `PATH`.
100
+
101
+ ```sh
102
+ npx sync-header-metadata # verify
103
+ npx sync-header-metadata --update # rewrite in place
104
+ ```
105
+
106
+ Pin a major (`sync-header-metadata@2`) or an exact version
107
+ (`sync-header-metadata@2.3.0`) for reproducibility; npm versions match the
108
+ action's `vX.Y.Z` release tags.
109
+
110
+ | Option | Default | Description |
111
+ | ---------------------------- | --------- | ------------------------------------------------------------- |
112
+ | `-m`, `--mode <mode>` | `verify` | `verify` exits non-zero on drift; `update` rewrites in place. |
113
+ | `-u`, `--update` | Off | Shorthand for `--mode update`. |
114
+ | `-r`, `--repo <owner/repo>` | See below | Repository the `~owner/repo.git` line is checked against. |
115
+ | `-v`, `--verbose` | Off | Enable INFO-level logging. |
116
+ | `-h`, `--help` | | Show usage. |
117
+
118
+ Without `--repo`, the repository is taken from `$GITHUB_REPOSITORY` if set,
119
+ otherwise from the `origin` remote's URL. Workflow annotations are never
120
+ emitted locally.
121
+
122
+ Only files in git's index are checked (`git ls-files`), exactly as in CI. A
123
+ new file isn't checked until it's been `git add`ed; untracked files are
124
+ skipped without warning.
125
+
126
+ To catch drift before it reaches CI, e.g. as a husky `pre-commit` hook
127
+ (staged files are in the index, so they're covered):
128
+
129
+ ```sh
130
+ npx --yes sync-header-metadata@2
131
+ ```
132
+
133
+ ### Exit codes
134
+
135
+ | Code | Meaning |
136
+ | ----- | -------------------------------------------------------------------------- |
137
+ | `0` | `verify` passed, or `update` completed. |
138
+ | `1` | `verify` found drift, or a fatal error (e.g. invalid mode, no repository). |
139
+ | `2` | Invalid command-line arguments, e.g. `--update` with `--mode verify`. |
140
+ | `127` | `git` not found on `PATH`. |
141
+
142
+ ### Ignoring files
143
+
144
+ To exclude a path, explicitly unset the `sync-header-metadata` boolean
145
+ attribute for it in `.gitattributes`:
146
+
147
+ ```gitattributes
148
+ vendor/** -sync-header-metadata
149
+ *.min.js -sync-header-metadata
150
+ /config.js -sync-header-metadata
151
+ ```
152
+
153
+ Standard `.gitattributes` matching applies:
154
+
155
+ - A bare pattern with no leading `/` matches at any depth.
156
+ - A leading `/` anchors it to that `.gitattributes` file's own directory.
157
+ - Nested `.gitattributes` files can re-enable syncing for a subtree per greater
158
+ specificity by setting the attribute back, e.g.
159
+ `important/** sync-header-metadata`.
160
+
161
+ #### Built-in defaults
162
+
163
+ The action ships a baseline exclusion list for files that structurally can't
164
+ carry a header comment, or are generated/lockfiles that shouldn't be
165
+ hand-edited:
166
+
167
+ ```gitattributes
168
+ /LICENSE* -sync-header-metadata
169
+ .keep -sync-header-metadata
170
+ *.json -sync-header-metadata
171
+ *.lock -sync-header-metadata
172
+ pnpm-lock.yaml -sync-header-metadata
173
+ go.sum -sync-header-metadata
174
+ *.min.js -sync-header-metadata
175
+ *.min.css -sync-header-metadata
176
+ ```
177
+
178
+ These are loaded at the lowest precedence, so they never need to be declared
179
+ in your own `.gitattributes`. Any matching line in your repo (set or
180
+ unset) always overrides a default, e.g. to re-enable syncing for one JSON
181
+ file despite the blanket `*.json` default:
182
+
183
+ ```gitattributes
184
+ config/version.json sync-header-metadata
185
+ ```
186
+
187
+
188
+ ## Limitations
189
+
190
+ ### Workflow files
191
+
192
+ `update` mode rewrites `.github/workflows/*.yaml` headers the same as any
193
+ other tracked file. By default, `GITHUB_TOKEN` cannot push a commit that
194
+ touches `.github/workflows/` without the `workflows: write` permission
195
+ explicitly granted in the calling workflow. Without it, the commit/push step
196
+ following this action (`git-auto-commit-action` or otherwise) fails the
197
+ entire commit, and all update writes per this action are lost.
198
+
199
+ If you haven't granted `workflows: write`, elide workflow files in the same
200
+ manner as any other ignored path or learn this security restriction at push.
201
+
202
+ ```gitattributes
203
+ .github/workflows/** -sync-header-metadata
204
+ ```
205
+
206
+ *(It's a very inconsequential failure. Handling involves either providing the
207
+ permission, excluding as shown, or manually updating the out-of-sync workflow
208
+ header.)*
209
+
210
+ ### Annotation limits
211
+
212
+ GitHub caps workflow annotations at 10 errors, 10 warnings, and 10 notices
213
+ per step, regardless of the `annotation` input. This action runs as a single
214
+ step and can emit up to two `error` annotations per drifted file (one for
215
+ the repo line, one for the path line), so a repo with more than a handful of
216
+ drifted files will exceed the cap: only the first 10 of each level render in
217
+ the PR's Checks/Files-changed UI, the rest are silently dropped by GitHub.
218
+
219
+ This doesn't affect correctness, the exit code and the plain `[ERROR]` log
220
+ lines printed to the job's raw log aren't subject to the cap, only the
221
+ `::error::`/`::warning::`/`::notice::` UI annotations are. Treat annotations
222
+ as a convenience for small drifts and rely on the job log or `mode: update`'s
223
+ diff for anything larger.
224
+
225
+ A GitHub check run is bound to a single commit (`head_sha`) for its whole
226
+ lifetime, and an annotation only renders as an inline bubble on that commit's
227
+ own "Files changed" page if the annotated file is part of *that specific
228
+ commit's* diff. This action scans every tracked file on each run, not just
229
+ what the triggering commit touched, so the use case matters:
230
+
231
+ - **`verify` mode gating a PR** that renamed or moved a file: the drifted
232
+ file is, by definition, part of that PR's own diff, so the annotation
233
+ lands inline exactly where it's useful. This is the primary intended use
234
+ case and where annotations work well.
235
+ - **`update` mode as a scheduled or manually dispatched sweep** across a
236
+ repo's whole tracked-file set (e.g. a periodic cleanup job): most flagged
237
+ files have nothing to do with whatever commit triggered that run, so most
238
+ annotations can't attach to a diff line at all. They still show up in the
239
+ workflow run's own Annotations summary panel, just without a working deep
240
+ link. Rely on the job log and exit code for this shape of run instead.
241
+
242
+ ## Development
243
+
244
+ A native `node24` action with no install step and no runtime dependencies.
245
+ Both entry points are thin wrappers around the same logic:
246
+
247
+ | File | Role |
248
+ | ----------------------------- | --------------------------------------------------------------- |
249
+ | `src/sync.js` | Core: resolves tracked files, checks/rewrites headers. |
250
+ | `run.js` | Action entry: reads `INPUT_*` and `GITHUB_REPOSITORY`. |
251
+ | `bin/sync-header-metadata.js` | CLI entry: reads flags, falls back to the `origin` remote. |
252
+ | `src/action_log.js` | Logging and `::error::`-style workflow-command annotations. |
253
+
254
+ Sanity-check changes locally, from inside a git checkout:
255
+
256
+ ```sh
257
+ node bin/sync-header-metadata.js --verbose # run the CLI against this repo
258
+ npm test # both entry points, in throwaway repos
259
+ ```
260
+
261
+ ## License
262
+
263
+ [GNU General Public License v3.0 only](LICENSE)
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // vim:set expandtab shiftwidth=4 filetype=javascript:
3
+ // SPDX-License-Identifier: GPL-3.0-only
4
+
5
+ //
6
+ //
7
+ // ~chewygumxx/sync-header-metadata.git
8
+ // ::: :/bin/sync-header-metadata.js
9
+ //
10
+ //
11
+
12
+ "use strict";
13
+
14
+ const { parseArgs } = require("node:util");
15
+ const { execFileSync } = require("node:child_process");
16
+
17
+ const ActionLog = require("../src/action_log.js");
18
+ const { sync } = require("../src/sync.js");
19
+
20
+ const USAGE = `\
21
+ Usage: sync-header-metadata [options]
22
+
23
+ Checks and updates the "~owner/repo.git" and "::: :/<path>" header lines
24
+ in the tracked files of the current git repository.
25
+
26
+ Options:
27
+ -m, --mode <verify|update> verify: fail if any header line is out-of-sync (default)
28
+ update: rewrite out-of-sync header lines
29
+ -u, --update Shorthand for --mode update
30
+ -r, --repo <owner/repo> Repository to sync against
31
+ (default: $GITHUB_REPOSITORY, then the origin remote)
32
+ -v, --verbose Enable INFO-level logging
33
+ -h, --help Show this help`;
34
+
35
+ // ----------------
36
+ // Parse Arguments
37
+ // ----------------
38
+
39
+ let args;
40
+ try {
41
+ ({ values: args } = parseArgs({
42
+ options: {
43
+ // No default: --update must be able to tell an explicit
44
+ // `--mode verify` (a contradiction) from an omitted --mode.
45
+ mode: { type: "string", short: "m" },
46
+ update: { type: "boolean", short: "u", default: false },
47
+ repo: { type: "string", short: "r" },
48
+ verbose: { type: "boolean", short: "v", default: false },
49
+ help: { type: "boolean", short: "h", default: false },
50
+ },
51
+ }));
52
+ } catch (err) {
53
+ console.error(`${err.message}\n\n${USAGE}`);
54
+ process.exit(2);
55
+ }
56
+
57
+ if (args.update && args.mode && args.mode.toLowerCase() !== "update") {
58
+ console.error(
59
+ `Option '--update' conflicts with '--mode ${args.mode}'\n\n${USAGE}`,
60
+ );
61
+ process.exit(2);
62
+ }
63
+
64
+ if (args.help) {
65
+ console.log(USAGE);
66
+ process.exit(0);
67
+ }
68
+
69
+ // Annotations are GitHub workflow commands; they are only noise in a terminal.
70
+ const log = new ActionLog(args.verbose, false);
71
+
72
+ const mode = args.update ? "update" : (args.mode ?? "verify").toLowerCase();
73
+ if (mode !== "verify" && mode !== "update")
74
+ log.fatal(
75
+ `Invalid mode: Must be 'verify' or 'update', received: ${args.mode}`,
76
+ );
77
+
78
+ // -------------------
79
+ // Resolve Repository
80
+ // -------------------
81
+
82
+ // Accepts the scp-like (git@host:owner/repo.git) and URL
83
+ // (https://host/owner/repo, ssh://git@host/owner/repo.git) remote forms.
84
+ const REMOTE_RE =
85
+ /^(?:[a-z+]+:\/\/[^/]+\/|[^@/]+@[^:]+:)(\S+?\/[^/\s]+?)(?:\.git)?\/?$/;
86
+ function repoFromOrigin() {
87
+ let url;
88
+ try {
89
+ url = execFileSync("git", ["remote", "get-url", "origin"], {
90
+ encoding: "utf8",
91
+ stdio: ["ignore", "pipe", "ignore"],
92
+ }).trim();
93
+ } catch {
94
+ return null;
95
+ }
96
+ const m = REMOTE_RE.exec(url);
97
+ return m ? m[1] : null;
98
+ }
99
+
100
+ const repository =
101
+ args.repo || process.env.GITHUB_REPOSITORY || repoFromOrigin();
102
+ if (!repository)
103
+ log.fatal(
104
+ "Could not resolve repository from the origin remote: Pass --repo <owner/repo>",
105
+ );
106
+
107
+ // ----
108
+ // Run
109
+ // ----
110
+
111
+ process.exit(sync({ mode, repository, log }));
@@ -0,0 +1,17 @@
1
+ # Bundled default exclusions for sync-header-metadata.
2
+ #
3
+ # Loaded as the lowest-precedence attribute source (via
4
+ # `core.attributesFile`), so any matching line in a consumer repo's own
5
+ # .gitattributes/info/attributes always wins over these defaults.
6
+ #
7
+ # These cover files that structurally can't carry a header comment, or are
8
+ # generated/lockfiles that shouldn't be hand-edited.
9
+
10
+ /LICENSE* -sync-header-metadata
11
+ .keep -sync-header-metadata
12
+ *.json -sync-header-metadata
13
+ *.lock -sync-header-metadata
14
+ pnpm-lock.yaml -sync-header-metadata
15
+ go.sum -sync-header-metadata
16
+ *.min.js -sync-header-metadata
17
+ *.min.css -sync-header-metadata
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "sync-header-metadata",
3
+ "description": "Checks and updates the \"~owner/repo.git\" and \"::: :/<path>\" header lines in tracked files.",
4
+ "version": "2.3.0",
5
+ "license": "GPL-3.0-only",
6
+ "keywords": [
7
+ "cli",
8
+ "github-actions",
9
+ "header",
10
+ "metadata",
11
+ "repository"
12
+ ],
13
+ "homepage": "https://github.com/chewygumxx/sync-header-metadata#readme",
14
+ "bugs": "https://github.com/chewygumxx/sync-header-metadata/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/chewygumxx/sync-header-metadata.git"
18
+ },
19
+ "bin": {
20
+ "sync-header-metadata": "bin/sync-header-metadata.js"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "src/",
25
+ "default.gitattributes"
26
+ ],
27
+ "engines": {
28
+ "node": ">=24"
29
+ },
30
+ "publishConfig": {
31
+ "provenance": true
32
+ },
33
+ "devDependencies": {
34
+ "@biomejs/biome": "2.5.14",
35
+ "@chewygumxx/biome-config": "^1.0.0",
36
+ "@chewygumxx/commitlint-config": "^1.0.0",
37
+ "@chewygumxx/cz-commitlint": "^1.0.2",
38
+ "@chewygumxx/remark-preset": "^1.0.3",
39
+ "@chewygumxx/yamllint-config": "^1.0.0",
40
+ "@commitlint/cli": "^21.2.2",
41
+ "@types/node": "^24.19.0",
42
+ "commitizen": "^4.3.2",
43
+ "husky": "^9.1.7",
44
+ "prettier": "^3.9.9",
45
+ "remark-cli": "^12.0.1",
46
+ "typescript": "^7.0.2"
47
+ },
48
+ "scripts": {
49
+ "check": "npm run typecheck && npm run lint:biome && npm run lint:md && npm run lint:yaml && npm run lint:emdash && npm test",
50
+ "commit": "cz",
51
+ "format": "biome format --write . && npm run format:yaml",
52
+ "format:check": "biome format .",
53
+ "format:yaml": "git ls-files -z '*.yaml' '*.yml' | xargs -0 -r prettier --write --log-level warn",
54
+ "lint": "biome lint .",
55
+ "lint:biome": "biome ci .",
56
+ "lint:emdash": "git grep -nIP --untracked '\\x{2014}'; test $? -eq 1",
57
+ "lint:md": "git ls-files -z '*.md' | xargs -0 -r remark --frail --quiet --no-stdout",
58
+ "lint:yaml": "git ls-files -z '*.yaml' '*.yml' | xargs -0 -r prettier --check && git ls-files -z '*.yaml' '*.yml' | xargs -0 -r yamllint --strict",
59
+ "prepare": "test -d node_modules/husky && husky || true",
60
+ "test": "node --test",
61
+ "typecheck": "tsc"
62
+ },
63
+ "config": {
64
+ "commitizen": {
65
+ "path": "@chewygumxx/cz-commitlint"
66
+ }
67
+ },
68
+ "remarkConfig": {
69
+ "plugins": [
70
+ "@chewygumxx/remark-preset"
71
+ ]
72
+ }
73
+ }
@@ -0,0 +1,89 @@
1
+ // vim:set expandtab shiftwidth=4 filetype=javascript:
2
+ // SPDX-License-Identifier: GPL-3.0-only
3
+
4
+ //
5
+ //
6
+ // ~chewygumxx/sync-header-metadata.git
7
+ // ::: :/src/action_log.js
8
+ //
9
+ //
10
+
11
+ "use strict";
12
+
13
+ function escapeData(value) {
14
+ return String(value)
15
+ .replace(/%/g, "%25")
16
+ .replace(/\r/g, "%0D")
17
+ .replace(/\n/g, "%0A");
18
+ }
19
+
20
+ function escapeProperty(value) {
21
+ return escapeData(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
22
+ }
23
+
24
+ function output(level, message) {
25
+ const output = typeof message === "string" ? message : message.join("\n");
26
+ console.log(`[${level.toUpperCase()}] ${output}`);
27
+ }
28
+
29
+ // GitHub's workflow-command annotation properties are named file/line/endLine/
30
+ // col/endColumn/title; startLine/endLine here mirror @actions/core's
31
+ // AnnotationProperties naming, translated to the wire names GitHub expects.
32
+ function annotate(command, opts) {
33
+ const commandProps = {
34
+ title: opts.title,
35
+ file: opts.file,
36
+ line: opts.startLine,
37
+ endLine: opts.endLine || opts.startLine,
38
+ };
39
+ const props = Object.entries(commandProps)
40
+ .filter(
41
+ ([, value]) =>
42
+ value !== undefined && value !== null && value !== "",
43
+ )
44
+ .map(([key, value]) => `${key}=${escapeProperty(value)}`)
45
+ .join(",");
46
+ console.log(
47
+ `::${command === "warn" ? "warning" : command}${props ? " " + props : ""}::${escapeData(opts.message)}`,
48
+ );
49
+ }
50
+
51
+ function wrap(command, opts, annotation_enabled) {
52
+ output(
53
+ command,
54
+ `${opts.title}: ${opts.message}${opts.file ? ` ${opts.file}` : ""}`,
55
+ );
56
+ if (annotation_enabled) annotate(command, opts);
57
+ }
58
+
59
+ class ActionLog {
60
+ constructor(verbose, annotation) {
61
+ this.verbose = verbose === true;
62
+ this.annotation = annotation === true;
63
+ }
64
+
65
+ info(message) {
66
+ if (!this.verbose) return;
67
+ output("info", message);
68
+ }
69
+ notice(opts) {
70
+ wrap("notice", opts, this.annotation);
71
+ }
72
+ warn(opts) {
73
+ wrap("warn", opts, this.annotation);
74
+ }
75
+ error(opts) {
76
+ wrap("error", opts, this.annotation);
77
+ }
78
+ fatal(message, code = 1) {
79
+ output("fatal", message);
80
+ if (this.annotation)
81
+ annotate("error", {
82
+ title: `[FATAL] ${message}`,
83
+ message: `[FATAL] ${message}`,
84
+ });
85
+ process.exit(typeof code === "number" ? code : 1);
86
+ }
87
+ }
88
+
89
+ module.exports = ActionLog;