Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/license-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
name: License Header Check

# Runs on this repo's own PRs, and is also callable from other repos:
# jobs:
# license-check:
# uses: thunderbird/pro/.github/workflows/license-check.yml@main
on:
pull_request:
branches: [main]
workflow_call:

permissions:
contents: read

jobs:
license-check:
runs-on: ubuntu-latest
steps:
- name: Checkout repo under test
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Checkout license-header tooling from thunderbird/pro
uses: actions/checkout@v7
with:
repository: thunderbird/pro
ref: ${{ github.workflow_sha }}
path: .pro-license-tools

- name: Check license headers on changed files
run: |
./.pro-license-tools/scripts/add-license-headers.sh --check \
--base "origin/${{ github.event.pull_request.base.ref }}"
37 changes: 37 additions & 0 deletions .license-ignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Default exclusions for scripts/add-license-headers.sh (gitignore syntax).
# Copy this file into your repo root and extend it with repo-specific paths;
# see docs/license-headers.md.

# VCS / CI config
.git/
.github/

# Dependencies / vendored code
node_modules/
vendor/
.venv/
venv/

# Build / generated output
dist/
build/
*.min.js
*.min.css

# Python
__pycache__/
*.pyc
migrations/

# Test fixtures deliberately missing/varying headers (scripts/tests/run-tests.sh)
scripts/tests/fixtures/

# Known lockfiles
package-lock.json
pnpm-lock.yaml
yarn.lock
poetry.lock
uv.lock
Pipfile.lock
composer.lock
Cargo.lock
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Regardless of which repo you plan to contribute to, general ettiquette for contr
### 4. Review & merge
At least one maintainer must approve the pull request before the change is merged. Often additional changes are requested and can be made in the same PR by updating your feature branch on your fork. Once all changes and comments have been adressed and approved, the PR is ready to be merged into the main project.

### 5. License headers
Source files need an MPL license header; see [docs/license-headers.md](docs/license-headers.md) for the script that adds/checks them and how to adopt the CI check in another repo.

## Need Help? Found a bug? Have an idea? Want to chat?
If any of these apps are not behaving like it should, or you are not sure if you've encountered a bug:
* Ask a question on our Matrix channel: [#tb-services:mozilla.org](https://matrix.to/#/#tb-services:mozilla.org)
Expand Down
57 changes: 57 additions & 0 deletions docs/license-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# MPL license headers

Thunderbird Pro repos are licensed under the [Mozilla Public License 2.0](../LICENSE), which is a per-file license: each covered source file should carry a short header pointing back at it. `scripts/add-license-headers.sh` adds and checks these headers, and `.github/workflows/license-check.yml` runs it in CI.

## Using the script

```sh
# Add missing headers to every tracked file in the repo
./scripts/add-license-headers.sh

# Only check (used by CI) - exits 1 and lists offending files if any are missing a header
./scripts/add-license-headers.sh --check --base main

# Only operate on specific files
./scripts/add-license-headers.sh -- path/to/file.py path/to/other.vue
```

Run `./scripts/add-license-headers.sh --help` for the full option list.

The script only touches file types it knows a comment style for (see `scripts/license-comment-styles.conf`); everything else (images, lockfiles, Markdown, JSON, ...) is left alone. It's idempotent - re-running it never adds a duplicate header.

## Excluding files

Paths matching `.license-ignore` (plain `.gitignore` syntax) are skipped, in addition to any file type absent from `scripts/license-comment-styles.conf`.

If a repo needs its own exclusions or comment-style entries, drop a same-named file at its own root - `.license-ignore` and/or `scripts/license-comment-styles.conf` - and it takes precedence over the copy bundled in `thunderbird/pro`.

## Adopting this in another repo

### Using CI

Add a caller workflow that invokes the reusable workflow hosted here:

```yaml
name: License Header Check
on:
pull_request:
jobs:
license-check:
uses: thunderbird/pro/.github/workflows/license-check.yml@main
```

This checks out your repo plus the tooling from `thunderbird/pro`, then runs `add-license-headers.sh --check` against the files your PR changed.

### Running locally, without CI

Clone `thunderbird/pro` next to your repo and invoke the script from your repo's root:

```sh
# Clone thunderbird/pro as a sibling of your repo
git clone https://github.com/thunderbird/pro.git .pro-license-tools

cd your-repo
../.pro-license-tools/scripts/add-license-headers.sh --check
```

`REPO_ROOT` is resolved from the repo you run it in (via `git rev-parse --show-toplevel`), so it scans and modifies only your repo's own tracked files - never the cloned tooling. If your repo doesn't have its own `.license-ignore` and/or `scripts/license-comment-styles.conf`, the script falls back to the copies bundled in `.pro-license-tools`; drop same-named files at your repo's root to override them.
204 changes: 204 additions & 0 deletions scripts/add-license-headers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# Add or check MPL 2.0 license headers on source files.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git rev-parse --show-toplevel)"

CHECK_MODE=0
BASE_REF=""
IGNORE_FILE="$REPO_ROOT/.license-ignore"
[ -f "$IGNORE_FILE" ] || IGNORE_FILE="$(dirname "$SCRIPT_DIR")/.license-ignore"
STYLE_MAP="$REPO_ROOT/scripts/license-comment-styles.conf"
[ -f "$STYLE_MAP" ] || STYLE_MAP="$SCRIPT_DIR/license-comment-styles.conf"
FILES=()
EXPLICIT_FILES=0

# Content for help and usage
usage() {
cat <<'EOF'
Check or add MPL 2.0 license headers on source files.

Usage:
add-license-headers.sh [--check] [--ignore-file PATH] [--style-map PATH] [--base REF]
add-license-headers.sh [--check] [--ignore-file PATH] [--style-map PATH] -- FILE...
add-license-headers.sh -h|--help

Modes:
(default) Insert the MPL header into any relevant file that's missing one.
--check Report-only: exit 1 if any relevant file is missing its header.

Target selection (mutually exclusive):
(no args) Scan every tracked file (git ls-files).
--base REF Only files changed relative to REF (git diff --name-only REF...HEAD).
-- FILE... Only the given file paths.

Options:
--ignore-file PATH Override the gitignore-syntax exclusion list
(default: <repo-root>/.license-ignore,
falling back to the copy in this script's own repo).
--style-map PATH Override the extension-to-comment-style table
(default: <repo-root>/scripts/license-comment-styles.conf,
falling back to the copy bundled next to this script).

Examples:
./scripts/add-license-headers.sh # add headers repo-wide
./scripts/add-license-headers.sh --check --base main # what CI runs
./scripts/add-license-headers.sh -- foo.py bar.vue # only these files
EOF
}

# Parse command-line arguments.
while [ $# -gt 0 ]; do
case "$1" in
--check) CHECK_MODE=1; shift ;;
--base) BASE_REF="$2"; shift 2 ;;
--ignore-file) IGNORE_FILE="$2"; shift 2 ;;
--style-map) STYLE_MAP="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
--) shift; FILES=("$@"); EXPLICIT_FILES=1; break ;;
*) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done

# Reject incompatible target-selection options.
if [ -n "$BASE_REF" ] && [ "$EXPLICIT_FILES" -eq 1 ]; then
echo "error: --base and -- FILE... are mutually exclusive" >&2
usage >&2
exit 2
fi

# Validate the base ref before it's used to select files.
if [ -n "$BASE_REF" ]; then
if ! git -C "$REPO_ROOT" rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then
echo "error: --base ref '$BASE_REF' does not resolve to a commit in this repository" >&2
exit 2
fi
fi

# Determine which files to scan when none were given explicitly.
if [ ${#FILES[@]} -eq 0 ]; then
if [ -n "$BASE_REF" ]; then
mapfile -t FILES < <(git -C "$REPO_ROOT" diff --name-only --diff-filter=ACMR "${BASE_REF}...HEAD")
else
mapfile -t FILES < <(git -C "$REPO_ROOT" ls-files)
fi
fi

# Substring shared verbatim by every style template below; used to detect existing license headers.
MARKER='This Source Code Form is subject to the terms of the Mozilla Public'

# Look up the comment style for a file extension in the style map.
style_for_ext() {
local ext="$1"
[ -f "$STYLE_MAP" ] || return 1
awk -v ext="$ext" '$1 == ext { print $2; found=1; exit } END { exit !found }' "$STYLE_MAP"
}

# Check whether a path matches the ignore file's gitignore-style patterns.
is_ignored() {
local path="$1"
[ -f "$IGNORE_FILE" ] || return 1
git -C "$REPO_ROOT" -c core.excludesFile="$IGNORE_FILE" check-ignore --no-index -q -- "$path"
}

# Render the license header text for a given comment style.
header_for_style() {
case "$1" in
slash)
printf '// %s\n// License, v. 2.0. If a copy of the MPL was not distributed with this\n// file, You can obtain one at http://mozilla.org/MPL/2.0/.\n' "$MARKER" ;;
hash)
printf '# %s\n# License, v. 2.0. If a copy of the MPL was not distributed with this\n# file, You can obtain one at http://mozilla.org/MPL/2.0/.\n' "$MARKER" ;;
block)
printf '/*\n * %s\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at http://mozilla.org/MPL/2.0/.\n */\n' "$MARKER" ;;
html)
printf '<!--\n %s\n License, v. 2.0. If a copy of the MPL was not distributed with this\n file, You can obtain one at http://mozilla.org/MPL/2.0/.\n-->\n' "$MARKER" ;;
jinja)
printf '{#\n %s\n License, v. 2.0. If a copy of the MPL was not distributed with this\n file, You can obtain one at http://mozilla.org/MPL/2.0/.\n#}\n' "$MARKER" ;;
*)
return 1 ;;
esac
}

# Insert after a shebang line if present, otherwise at the top of the file.
insert_header() {
local target="$1" header="$2" tmp mode
tmp="$(mktemp)"
if [[ "$(head -n1 -- "$target")" == '#!'* ]]; then
{ head -n1 -- "$target"; printf '\n%s\n' "$header"; tail -n +2 -- "$target"; } > "$tmp"
else
{ printf '%s\n' "$header"; cat -- "$target"; } > "$tmp"
fi
mode="$(stat -c '%a' -- "$target" 2>/dev/null || stat -f '%Lp' -- "$target")"
chmod "$mode" "$tmp"
mv -- "$tmp" "$target"
}

# Memory for report
missing=()
already_had=0
ignored_count=0
skipped_symlinks=0

# Process each candidate file, adding or checking its license header.
for file in "${FILES[@]}"; do
# Resolve the absolute path differently depending on how the file was selected.
if [ "$EXPLICIT_FILES" -eq 1 ]; then
path="$file"
case "$path" in
/*) : ;; # already absolute
*) path="$PWD/$path" ;; # anchor to the invoking cwd, not $REPO_ROOT, so is_ignored's
# "git -C $REPO_ROOT check-ignore" resolves the right file
esac
else
path="$REPO_ROOT/$file"
fi

# Handle symlinks: never flatten a tracked symlink into a regular file
if [ -L "$path" ]; then
skipped_symlinks=$((skipped_symlinks + 1))
continue
fi
[ -f "$path" ] || continue

base="${file##*/}"
ext="${base##*.}"
[ "$ext" = "$base" ] && continue # no dot at all, e.g. "Makefile"
[ -z "${base%.*}" ] && continue # dotfile with nothing before the (only) dot, e.g. ".gitignore"

style="$(style_for_ext "$ext")" || continue # extension not in style map

# Skip files excluded by the ignore list.
if is_ignored "$path"; then
ignored_count=$((ignored_count + 1))
continue
fi

# Skip files that already have the license header.
if grep -qF -- "$MARKER" "$path"; then
already_had=$((already_had + 1))
continue
fi

# Record as missing in check mode, otherwise insert the header now.
if [ "$CHECK_MODE" -eq 1 ]; then
missing+=("$file")
else
insert_header "$path" "$(header_for_style "$style")"
echo "added header: $file"
fi
done

# Report results: fail with the missing-file list in check mode, else print a summary.
if [ "$CHECK_MODE" -eq 1 ]; then
if [ ${#missing[@]} -gt 0 ]; then
echo "Missing MPL license header in ${#missing[@]} file(s):" >&2
printf ' %s\n' "${missing[@]}" >&2
echo >&2
echo "Re-run '$0' without --check to write them to the files, then commit." >&2
exit 1
fi
echo "All relevant files have license headers."
else
echo "Done. ${already_had} file(s) already had a header, ${ignored_count} ignored, ${skipped_symlinks} symlink(s) skipped."
fi
30 changes: 30 additions & 0 deletions scripts/license-comment-styles.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Extension -> comment-style mapping used by add-license-headers.sh.
# One "ext style" pair per line. Extensions not listed here are skipped.
# Styles: slash (//), hash (#), block (/* */), html (<!-- -->), jinja ({# #})
#
# A repo can override this file by placing its own copy at scripts/license-comment-styles.conf.

js slash
jsx slash
ts slash
tsx slash
mjs slash
cjs slash

py hash
sh hash
bash hash
yml hash
yaml hash
ftl hash

css block
scss block
less block

html html
htm html
vue html

jinja2 jinja
j2 jinja
1 change: 1 addition & 0 deletions scripts/tests/fixtures/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SAMPLE_VAR=1
2 changes: 2 additions & 0 deletions scripts/tests/fixtures/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
all:
echo "no dot in this filename at all"
5 changes: 5 additions & 0 deletions scripts/tests/fixtures/already-has-header.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
echo "already licensed"
3 changes: 3 additions & 0 deletions scripts/tests/fixtures/component.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<p>Sample component.</p>
</template>
4 changes: 4 additions & 0 deletions scripts/tests/fixtures/no-shebang.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// A tiny sample module used to test header insertion.
export function greet(name) {
return `Hello, ${name}!`;
}
Loading