Files
changedetection.io/.github/workflows/test-only.yml
T
Thibaud-Vdb 530a3d5fd6 ci: keep release metadata out of inline shell and lock down the container publish path (#4510)
* ci: pin third party actions to commit SHAs

A tag or branch ref is a movable pointer, whoever controls the action
repository can repoint it and the next run executes their code with
this repo's secrets, here the Docker Hub tokens and the container
publish path. The March 2025 tj-actions/changed-files incident
(CVE-2025-30066) worked exactly that way. Each pin keeps the resolved
tag in a comment and dependabot's weekly github-actions config bumps
SHA pins like tags, so they self-maintain. The pypi publish action
moves from the floating release/v1 branch to its current release.

* ci: declare least-privilege workflow permissions

Without a permissions block every job token inherits the repository
default scope. Each workflow now declares what it uses. The test and
build workflows get read-only tokens, CodeQL keeps its code scanning
upload, the container publish job keeps packages write for the ghcr
push, and the pypi publish job already carried its own id-token block
for trusted publishing, untouched.

* ci: pass release metadata to inline scripts through env

The two debug steps interpolated the release tag name and the
workflow_run branch directly into bash. Those strings are event
metadata that can carry shell syntax, and the workflow runs on
release events with the publish credentials in reach. Routed through
env they arrive as plain data, output unchanged.

* ci: stop persisting the checkout token in .git/config

actions/checkout writes the job token into .git/config so later steps can
push. None of these 21 jobs push with git: the containers are published
with registry credentials, PyPI uses trusted publishing, and the test
and CodeQL jobs only read the tree. A token left in .git/config is one
more credential for a compromised step or a packed artifact to pick up,
so each checkout now sets persist-credentials: false.
2026-10-09 20:12:35 +02:00

161 lines
6.8 KiB
YAML

name: ChangeDetection.io App Test
# Triggers the workflow on push or pull request events
on: [push, pull_request]
permissions:
contents: read
jobs:
lint-code:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Lint with Ruff
run: |
pip install ruff
# Check for syntax errors and undefined names, and gettext misuse
ruff check . --select E9,F63,F7,F82,INT
# Complete check with errors treated as warnings
ruff check . --exit-zero
- name: Validate OpenAPI spec
run: |
pip install openapi-spec-validator
python3 -c "from openapi_spec_validator import validate_spec; import yaml; validate_spec(yaml.safe_load(open('docs/api-spec.yaml')))"
lint-translations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Check .po files with msgfmt
run: |
sudo apt-get install -y gettext
find changedetectionio/translations -name "*.po" | while read f; do
echo "Checking $f"
msgfmt --check-format -o /dev/null "$f"
done
- name: Lint .pot template with dennis
run: |
pip install "$(grep -E '^dennis ?>=' requirements-dev.txt)"
dennis-cmd lint --strict changedetectionio/translations/messages.pot
- name: Lint .po files with dennis
run: |
dennis-cmd lint --strict --excluderules=W302 changedetectionio/translations/*/LC_MESSAGES/messages.po
# W302 (unchanged) is excluded due to high false-positive rate in this codebase:
# many msgstrs intentionally match msgid (units like "AI", "LLM", and proper nouns).
- name: Check translation catalog is up-to-date
run: |
pip install "$(grep -E '^babel==' requirements.txt)"
python setup.py extract_messages
python setup.py update_catalog
python setup.py compile_catalog
# Ignore POT-Creation-Date timestamp lines — they change on every extract_messages run
if git diff changedetectionio/translations | grep -v 'POT-Creation-Date' | grep -qE '^[+-][^+-]'; then
echo "ERROR: Translation catalog is out of sync. Run: python setup.py extract_messages && python setup.py update_catalog && python setup.py compile_catalog"
git diff --stat changedetectionio/translations
exit 1
fi
- name: Check translation overlay
# Deliberately after extract_messages above, so overrides are validated against a freshly
# extracted messages.pot. An overlay entry is keyed on the exact upstream msgid: when a
# string is reworded upstream the override stops matching and silently reverts to upstream
# wording. This is the only thing that makes that visible.
# See changedetectionio/translations_overlay/README.md
if: hashFiles('changedetectionio/translations_overlay/**/*.po') != ''
run: |
find changedetectionio/translations_overlay -name "*.po" | while read f; do
echo "Checking $f"
msgfmt --check-format -o /dev/null "$f"
done
python changedetectionio/translations_overlay/manage.py check
lint-template-i18n:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Check for fragmented gettext calls in templates
run: |
python3 << 'PYEOF'
import re, sys
from pathlib import Path
# Detects adjacent {{ _(...) }} calls on the same line separated only by HTML
# tags, whitespace, or non-translating Jinja2 variables — the anti-pattern of
# splitting a single sentence across multiple msgids.
# See https://github.com/dgtlmoon/changedetection.io/issues/4074 for background.
#
# The correct fix is to consolidate fragments into one entire-sentence msgid,
# injecting dynamic values via %(name)s kwargs — per the GNU gettext manual
# sections "Entire sentences" and "No string concatenation". See PR #4076 for
# worked examples of each consolidation pattern.
#
# BASELINE: this limit reflects pre-existing violations present when this check
# was introduced. It must only ever go DOWN. Each time you fix a template, lower
# the limit by the number of lines fixed so the improvement is locked in.
# When the count reaches 0, replace the baseline check with a hard sys.exit(1).
BASELINE_LIMIT = 44
FRAGMENT_RE = re.compile(
r'\{\{[^{}]*\b_\s*\([^)]*\)[^{}]*\}\}'
r'(?:\s*(?:<[^>]+>|\{\{(?![^}]*\b_\s*\()[^}]*\}\})\s*)+'
r'\{\{[^{}]*\b_\s*\([^)]*\)[^{}]*\}\}'
)
violations = []
for f in sorted(Path('changedetectionio').rglob('*.html')):
for lineno, line in enumerate(f.read_text().splitlines(), 1):
if FRAGMENT_RE.search(line):
violations.append(f"{f}:{lineno}: {line.strip()[:120]}")
count = len(violations)
print(f"Fragmented i18n calls found: {count} (limit: {BASELINE_LIMIT})")
for v in violations:
print(v)
if count > BASELINE_LIMIT:
print(f"\nERROR: {count} fragmented gettext calls exceed the baseline of {BASELINE_LIMIT}.")
print("Consolidate adjacent _() calls into a single entire-sentence msgid.")
print("See https://github.com/dgtlmoon/changedetection.io/issues/4074 for patterns.")
sys.exit(1)
PYEOF
test-application-3-11:
# Always run
needs: [lint-code, lint-translations, lint-template-i18n]
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.11'
test-application-3-12:
# Only run on push to master (including PR merges)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: [lint-code, lint-translations, lint-template-i18n]
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.12'
skip-pypuppeteer: true
test-application-3-13:
# Only run on push to master (including PR merges)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: [lint-code, lint-translations, lint-template-i18n]
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.13'
skip-pypuppeteer: true
test-application-3-14:
#if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: [lint-code, lint-translations, lint-template-i18n]
uses: ./.github/workflows/test-stack-reusable-workflow.yml
with:
python-version: '3.14'
skip-pypuppeteer: false