diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8292217 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Enforce LF for source and config files so Prettier checks are stable across environments. +*.ts text eol=lf +*.js text eol=lf +*.vue text eol=lf +*.json text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.md text eol=lf +*.toml text eol=lf +*.py text eol=lf +*.sh text eol=lf +*.xsh text eol=lf diff --git a/.gitea/workflows/cicd-checks.yaml b/.gitea/workflows/cicd-checks.yaml index bfa5e26..9e2f49d 100644 --- a/.gitea/workflows/cicd-checks.yaml +++ b/.gitea/workflows/cicd-checks.yaml @@ -163,6 +163,31 @@ jobs: fi fi + - name: Validate CICD image provenance + env: + HEAD_SHA: ${{ needs.setup.outputs.head_sha }} + run: | + IMAGE="${GITEA_REGISTRY}/darkhelm.org/plex-playlist-cicd:${HEAD_SHA}" + SOURCE_SHA=$(docker run --rm --entrypoint /bin/sh "${IMAGE}" -c 'cat /workspace/.cicd-source-sha 2>/dev/null || true') + + echo "requested_head_sha=${HEAD_SHA}" + echo "image=${IMAGE}" + echo "embedded_source_sha=${SOURCE_SHA:-missing}" + + if [ -z "${SOURCE_SHA}" ]; then + echo "❌ Missing /workspace/.cicd-source-sha in ${IMAGE}" + exit 1 + fi + + case "${SOURCE_SHA}" in + ${HEAD_SHA}*) + ;; + *) + echo "❌ Source SHA mismatch: requested=${HEAD_SHA} actual=${SOURCE_SHA}" + exit 1 + ;; + esac + - name: Run pre-commit hook env: HEAD_SHA: ${{ needs.setup.outputs.head_sha }} @@ -180,6 +205,8 @@ jobs: git config --global user.email 'ci@cicd' && git config --global user.name 'CICD' && git init /workspace && + git -C /workspace config core.autocrlf false && + git -C /workspace config core.eol lf && git -C /workspace add -A fi && /workspace/backend/.venv/bin/pre-commit run ${HOOK} --all-files --show-diff-on-failure diff --git a/Dockerfile.cicd b/Dockerfile.cicd index f64e580..4587314 100644 --- a/Dockerfile.cicd +++ b/Dockerfile.cicd @@ -34,8 +34,10 @@ RUN --mount=type=secret,id=ssh_private_key \ (ssh-keyscan -p 2222 kankali.darkhelm.lan >> ~/.ssh/known_hosts 2>/dev/null || echo "Warning: ssh-keyscan failed, continuing with StrictHostKeyChecking=no") && \ echo "=== Extracting dependency files for optimized caching ===" && \ GIT_SSH_COMMAND="ssh -F ~/.ssh/config" \ - git clone --depth 1 --branch main \ + git -c core.autocrlf=false -c core.eol=lf clone --depth 1 --branch main \ ssh://git@kankali.darkhelm.lan:2222/DarkHelm.org/plex-playlist.git /tmp/repo && \ + git -C /tmp/repo config core.autocrlf false && \ + git -C /tmp/repo config core.eol lf && \ if [ -n "$GITHUB_SHA" ]; then \ cd /tmp/repo && \ if GIT_SSH_COMMAND="ssh -F ~/.ssh/config" git fetch --depth 1 origin "$GITHUB_SHA" >/tmp/git-fetch-sha.log 2>&1 && \ diff --git a/Dockerfile.cicd-base b/Dockerfile.cicd-base index b53d4aa..2ac146b 100644 --- a/Dockerfile.cicd-base +++ b/Dockerfile.cicd-base @@ -121,7 +121,7 @@ RUN echo "=== Installing Global Development Tools ===" && \ @playwright/test@1.56.1 \ typescript@5.3.3 \ eslint@9.33.0 \ - prettier@3.3.3 \ + prettier@3.6.2 \ vite@7.1.10 \ @types/node@20.16.0 && \ # Verify global tools are available diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json index 29b9d1f..a2d42ed 100644 --- a/frontend/.prettierrc.json +++ b/frontend/.prettierrc.json @@ -4,5 +4,6 @@ "singleQuote": true, "printWidth": 100, "tabWidth": 2, - "useTabs": false + "useTabs": false, + "endOfLine": "lf" } diff --git a/frontend/package.json b/frontend/package.json index abe318f..e5248c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "eslint-plugin-tsdoc": "^0.3.0", "eslint-plugin-vue": "^9.28.0", "jsdom": "^23.0.0", - "prettier": "^3.0.0", + "prettier": "3.6.2", "typescript": "~5.3.0", "vite": "^7.1.10", "vitest": "^3.2.4", diff --git a/scripts/gitea-actions/compare_remote_prettier_files.xsh b/scripts/gitea-actions/compare_remote_prettier_files.xsh new file mode 100755 index 0000000..27e699d --- /dev/null +++ b/scripts/gitea-actions/compare_remote_prettier_files.xsh @@ -0,0 +1,83 @@ +#!/usr/bin/env xonsh + +import hashlib +import pathlib +import re +import shlex +import sys + +HOSTS = [ + 'kankali.darkhelm.lan', + 'zhokq.darkhelm.lan', + 'urtzul.darkhelm.lan', + 'pi-desktop.darkhelm.lan', +] + +FILES = [ + 'frontend/src/main.test.ts', + 'frontend/src/main.ts', + 'frontend/src/test-setup.ts', + 'frontend/src/validation.test.ts', + 'frontend/src/validation.ts', +] + +REGISTRY_IMAGE = 'kankali.darkhelm.lan:3001/darkhelm.org/plex-playlist-cicd' + +def local_hashes(root: pathlib.Path): + out = {} + for rel in FILES: + p = root / rel + out[rel] = hashlib.sha256(p.read_bytes()).hexdigest() + return out + +def remote_hashes(host: str, image_tag: str): + files = ' '.join(FILES) + cmd = ( + f"docker pull {image_tag} >/dev/null && " + f"docker run --rm --entrypoint /bin/sh {image_tag} " + f"-c {shlex.quote('cd /workspace && sha256sum ' + files)}" + ) + run = !(ssh -T @(host) sh -lc @(cmd)) + if run.returncode != 0: + return None, str(run.out).strip() + + hashes = {} + for line in str(run.out).splitlines(): + m = re.match(r'^([0-9a-f]{64})\s+(.+)$', line.strip()) + if m: + hashes[m.group(2)] = m.group(1) + return hashes, '' + +arg = sys.argv[1] if len(sys.argv) > 1 else 'latest' +image = f'{REGISTRY_IMAGE}:{arg}' + +root = pathlib.Path.cwd() +local = local_hashes(root) + +print(f'image={image}') +print('local hashes:') +for f in FILES: + print(f' {f} {local[f]}') + +for host in HOSTS: + print(f'\n=== {host} ===') + rh, err = remote_hashes(host, image) + if rh is None: + print('REMOTE_CHECK_FAILED') + if err: + print(err) + continue + + mismatches = 0 + for f in FILES: + lv = local.get(f, '') + rv = rh.get(f, '') + status = 'MATCH' if lv == rv else 'MISMATCH' + if status == 'MISMATCH': + mismatches += 1 + print(f'{status} {f} local={lv} remote={rv}') + + if mismatches == 0: + print('RESULT:ALL_MATCH') + else: + print(f'RESULT:MISMATCHES={mismatches}') diff --git a/scripts/gitea-actions/run_remote_cicd_prettier_check.xsh b/scripts/gitea-actions/run_remote_cicd_prettier_check.xsh new file mode 100755 index 0000000..74f9614 --- /dev/null +++ b/scripts/gitea-actions/run_remote_cicd_prettier_check.xsh @@ -0,0 +1,161 @@ +#!/usr/bin/env xonsh + +import shlex +import sys + +DEFAULT_HOSTS = [ + "kankali.darkhelm.lan", + "zhokq.darkhelm.lan", + "urtzul.darkhelm.lan", + "pi-desktop.darkhelm.lan", +] + +REGISTRY = "kankali.darkhelm.lan:3001" +IMAGE_REPO = "darkhelm.org/plex-playlist-cicd" + +USAGE = """Usage: + source scripts/gitea-actions/run_remote_cicd_prettier_check.xsh [local|latest|sha] [host1 host2 ...] + xonsh scripts/gitea-actions/run_remote_cicd_prettier_check.xsh [local|latest|sha] [host1 host2 ...] + +Examples: + # Use local HEAD short SHA on all runner hosts + source scripts/gitea-actions/run_remote_cicd_prettier_check.xsh + + # Use explicit SHA on all runner hosts + source scripts/gitea-actions/run_remote_cicd_prettier_check.xsh 968ee2a + + # Use latest tag on all runner hosts + source scripts/gitea-actions/run_remote_cicd_prettier_check.xsh latest + + # Source-safe override when your xonsh does not pass args to sourced scripts + $CICD_TAG = "latest" + source scripts/gitea-actions/run_remote_cicd_prettier_check.xsh + + # Execute directly (always supports positional args) + xonsh scripts/gitea-actions/run_remote_cicd_prettier_check.xsh latest + + # Use local HEAD on selected hosts + source scripts/gitea-actions/run_remote_cicd_prettier_check.xsh local kankali.darkhelm.lan urtzul.darkhelm.lan +""" + + +def resolve_tag(arg_value: str) -> str: + if not arg_value or arg_value == "local": + sha_cmd = !(git rev-parse --short HEAD) + if sha_cmd.returncode != 0: + print("ERROR: Unable to resolve local HEAD SHA via git rev-parse --short HEAD") + raise SystemExit(2) + return str(sha_cmd.out).strip().splitlines()[0] + if arg_value in ("latest", "main"): + return "latest" + if arg_value in ("-h", "--help", "help"): + print(USAGE) + raise SystemExit(0) + if arg_value: + return arg_value + return "latest" + + +def sourced_args_fallback() -> list[str]: + # `source file.xsh arg1 arg2` does not always populate sys.argv in xonsh. + # Try to read source-passed args from xonsh context variables. + try: + ctx = __xonsh__.ctx # type: ignore[name-defined] + except NameError: + return [] + + for key in ("args", "ARGS", "__args__"): + value = ctx.get(key) + if isinstance(value, (list, tuple)) and value: + return [str(v) for v in value] + return [] + + +def env_overrides() -> tuple[str | None, list[str] | None]: + try: + env = __xonsh__.env # type: ignore[name-defined] + except NameError: + return None, None + + tag = env.get("CICD_TAG") or env.get("REMOTE_CICD_TAG") + hosts_raw = env.get("CICD_HOSTS") or env.get("REMOTE_CICD_HOSTS") + hosts = None + if hosts_raw: + text = str(hosts_raw).replace(",", " ") + hosts = [h for h in text.split() if h] + return (str(tag) if tag else None), hosts + + +cli_args = list(sys.argv[1:]) +if not cli_args: + cli_args = sourced_args_fallback() + +env_tag, env_hosts = env_overrides() + +first_arg = cli_args[0] if cli_args else (env_tag or "local") +tag = resolve_tag(first_arg) + +if len(cli_args) > 1: + hosts = cli_args[1:] +elif env_hosts: + hosts = env_hosts +else: + hosts = DEFAULT_HOSTS + +image = f"{REGISTRY}/{IMAGE_REPO}:{tag}" +container_cmd = ( + "set -eu; " + "if [ -f /workspace/.cicd-source-sha ]; then " + " ACTUAL_SHA=$(cat /workspace/.cicd-source-sha); " + " echo source_sha=${ACTUAL_SHA}; " + "else " + " ACTUAL_SHA=unknown; " + " echo source_sha=missing; " + "fi; " + "if [ -n \"${EXPECTED_SHA:-}\" ]; then " + " case \"${ACTUAL_SHA}\" in " + " ${EXPECTED_SHA}*) : ;; " + " *) echo REMOTE_ERROR:source-sha-mismatch expected=${EXPECTED_SHA} actual=${ACTUAL_SHA}; exit 13 ;; " + " esac; " + "fi; " + "cd /workspace/frontend && corepack yarn prettier --version && corepack yarn prettier --check src/" +) +expected_sha = "" if tag == "latest" else tag +remote_cmd = ( + f"echo image={image}; " + f"if ! docker pull {image}; then echo REMOTE_ERROR:image-missing-or-unpublished; exit 12; fi; " + f"docker run --rm -e CI=true -e EXPECTED_SHA={expected_sha} --entrypoint /bin/sh {image} -c \"{container_cmd}\"" +) +ssh_remote = f"sh -lc {shlex.quote(remote_cmd)}" + +print(f"Resolved tag: {tag}") +print(f"Image: {image}") +print(f"Hosts: {', '.join(hosts)}") + +if not cli_args and not env_tag: + print("INFO: No positional args were detected. If you used `source ... latest`, your xonsh may not pass source args.") + print("INFO: Use `xonsh scripts/gitea-actions/run_remote_cicd_prettier_check.xsh latest` or set `$CICD_TAG = \"latest\"` before sourcing.") + +if tag != "latest": + branch_cmd = !(git rev-parse --abbrev-ref HEAD) + branch = str(branch_cmd.out).strip().splitlines()[0] if branch_cmd.returncode == 0 else "unknown" + if branch not in ("main", "develop"): + print("INFO: current branch is not main/develop; commit-tagged CICD images may not exist unless dispatched manually.") + +failures = [] +for host in hosts: + print(f"\n=== {host} ===") + run = !(ssh -T @(host) @(ssh_remote)) + if run.returncode != 0: + failures.append(host) + print(f"RESULT:{host}:FAIL") + else: + print(f"RESULT:{host}:PASS") + +if failures: + print("\nFailed hosts:") + for host in failures: + print(f"- {host}") + raise SystemExit(1) + +print("\nAll remote checks passed.")