diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db9b872..466409f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,17 +19,22 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + - uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 with: version: 2026.10.1 - name: Install native test tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends gjs gnome-shell libglib2.0-bin librsvg2-common gir1.2-soup-3.0 gir1.2-secret-1 dbus-daemon + run: bash scripts/native_tools.sh - run: npm ci --ignore-scripts - - run: ./node_modules/.bin/playwright install --with-deps chromium firefox + - name: Install browser engines + run: | + for attempt in 1 2 3; do + ./node_modules/.bin/playwright install --with-deps chromium firefox && exit 0 + echo "::warning::Playwright install attempt $attempt failed; retrying" + sleep 15 + done + exit 1 - run: just ci - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: extension-bundle path: '*.shell-extension.zip' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ba150f..0cf879a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + - uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 with: version: 2026.10.1 cache: false @@ -50,14 +50,14 @@ jobs: contents: write # Create the release and attach its tested ZIP. actions: read # Inspect workflow runs and download their tested artifacts. steps: - - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + - uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 with: version: 2026.10.1 cache: false mise_toml: | [tools] gh = "2.102.0" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: extension-bundle run-id: ${{ needs.verify.outputs.run }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3352dd5..5c5dc89 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -22,11 +22,11 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: javascript-typescript,python queries: security-extended - - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 scanners: name: scanners if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' @@ -37,7 +37,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + - uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 with: version: 2026.10.1 - run: npm ci --ignore-scripts diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 5685c68..292f46c 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -22,7 +22,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + - uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 with: version: 2026.10.1 - name: Require Sonar configuration @@ -31,9 +31,7 @@ jobs: run: test -n "$SONAR_TOKEN" || { echo '::error::SONAR_TOKEN is required; scan cannot be skipped'; exit 1; } - run: npm ci --ignore-scripts - name: Install native test tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends gjs gnome-shell libglib2.0-bin librsvg2-common gir1.2-soup-3.0 gir1.2-secret-1 dbus-daemon + run: bash scripts/native_tools.sh - run: just coverage - name: Analyze the checked pull request or main branch env: diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml index 8b2f8b4..53df502 100644 --- a/.github/workflows/template.yml +++ b/.github/workflows/template.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + - uses: jdx/mise-action@82a612c5ab8bcda7cc5db6b7e81a8e7584c3dc6e # v5.2.3 with: version: 2026.10.1 - run: just template-check template-status diff --git a/quick-template.lock.json b/quick-template.lock.json index 8bc7fe0..03254cb 100644 --- a/quick-template.lock.json +++ b/quick-template.lock.json @@ -1,5 +1,5 @@ { "$schema": "quick-template.schema.json", "repository": "Ghost-Assembly/quick-template", - "revision": "cfe18978694f233ef2874a063a8fee236f691943" + "revision": "846ae471eb8c3599d408aad70d8c14c57a91fdc8" } diff --git a/scripts/native_tools.sh b/scripts/native_tools.sh new file mode 100644 index 0000000..1b6b491 --- /dev/null +++ b/scripts/native_tools.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Install native test tools on a hosted runner without hanging on a stalled mirror. +# Each download runs under a deadline and is retried; after a failure apt switches +# from the runner's Azure mirror to archive.ubuntu.com. Packages that did arrive +# stay in apt's cache, so a retry resumes. Only the final offline install runs +# dpkg, and nothing interrupts it. +set -euo pipefail + +readonly attempts=3 +readonly deadline=150 # Seconds per apt command; a healthy mirror needs under ten. +readonly apt=(apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30) +readonly install=(install -y --no-install-recommends gjs gnome-shell libglib2.0-bin + librsvg2-common gir1.2-soup-3.0 gir1.2-secret-1 dbus-daemon) + +# coreutils timeout runs under sudo so it signals apt itself, not only sudo. +fetch() { + sudo timeout "$deadline" "${apt[@]}" update && + sudo timeout "$deadline" "${apt[@]}" "${install[@]}" --download-only +} + +# Hosted runners list mirrors in apt-mirrors.txt, which apt leaves only on errors. +switch_mirror() { + local apt_dir="$1" stale + mapfile -t stale < <(grep -lsF azure.archive.ubuntu.com "$apt_dir"/apt-mirrors.txt \ + "$apt_dir"/sources.list "$apt_dir"/sources.list.d/* || true) + if ((${#stale[@]})); then + sudo sed -i 's/azure[.]archive[.]ubuntu[.]com/archive.ubuntu.com/g' "${stale[@]}" + fi +} + +main() { + local apt_dir="${1:-/etc/apt}" attempt + for ((attempt = 1; ; attempt++)); do + fetch && break + if ((attempt == attempts)); then + echo "::error::Native test tools did not download in $attempts attempts" + exit 1 + fi + echo "::warning::apt attempt $attempt failed or stalled; retrying via archive.ubuntu.com" + switch_mirror "$apt_dir" + done + sudo "${apt[@]}" "${install[@]}" +} + +main "$@" diff --git a/tests/test_tooling.py b/tests/test_tooling.py index 56bea78..4250b57 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -5,6 +5,7 @@ import io import json import os +import shutil import subprocess import sys import tempfile @@ -576,5 +577,80 @@ def test_cleanup_preserves_source_and_external_symlink_targets(self) -> None: self.assertEqual(list(root.glob("*.shell-extension.zip")), []) +class NativeToolsTests(unittest.TestCase): + def install(self, codes: list[int]) -> tuple[subprocess.CompletedProcess, list[str], Path]: + """Run the real installer with a sudo stand-in that replays apt exit codes.""" + work = Path(self.enterContext(tempfile.TemporaryDirectory())) + (work / "bin").mkdir() + (work / "codes").write_text("".join(f"{code}\n" for code in codes)) + sudo = work / "bin/sudo" + sudo.write_text( + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$*" >> "$WORK/log"\n' + 'if [ "$1" = sed ]; then exec "$@"; fi\n' + 'code="$(head -n1 "$WORK/codes")"; sed -i 1d "$WORK/codes"; exit "${code:-0}"\n' + ) + sudo.chmod(0o755) + (work / "log").touch() + shutil.copyfile(ROOT / "scripts/native_tools.sh", work / "native_tools.sh") + (work / "apt/sources.list.d").mkdir(parents=True) + (work / "apt/apt-mirrors.txt").write_text( + "http://azure.archive.ubuntu.com/ubuntu/\tpriority:1\n" + "https://archive.ubuntu.com/ubuntu/\tpriority:2\n" + ) + (work / "apt/sources.list").write_text( + "deb http://azure.archive.ubuntu.com/ubuntu noble main\n" + ) + (work / "apt/sources.list.d/ubuntu.sources").write_text( + "URIs: mirror+file:/etc/apt/apt-mirrors.txt\n" + ) + (work / "apt/sources.list.d/other.sources").write_text("URIs: https://example.invalid/\n") + result = subprocess.run( + ["/usr/bin/env", "bash", "native_tools.sh", "apt"], + cwd=work, + env={**os.environ, "PATH": f"{work / 'bin'}:{os.environ['PATH']}", "WORK": str(work)}, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + return result, (work / "log").read_text().splitlines(), work / "apt" + + def test_healthy_mirror_downloads_once_and_installs_offline(self) -> None: + result, log, apt = self.install([]) + self.assertEqual(result.returncode, 0) + self.assertEqual([line.split()[0] for line in log], ["timeout", "timeout", "apt-get"]) + self.assertIn("--download-only", log[1]) + self.assertNotIn("--download-only", log[2]) + self.assertIn("azure.archive", (apt / "apt-mirrors.txt").read_text()) + + def test_stalled_mirror_switches_only_matching_sources_and_retries(self) -> None: + result, log, apt = self.install([124]) + self.assertEqual(result.returncode, 0) + self.assertIn("::warning::apt attempt 1", result.stdout) + self.assertNotIn("azure", (apt / "apt-mirrors.txt").read_text()) + self.assertEqual( + (apt / "sources.list").read_text(), "deb http://archive.ubuntu.com/ubuntu noble main\n" + ) + for name, contents in [ + ("ubuntu.sources", "URIs: mirror+file:/etc/apt/apt-mirrors.txt\n"), + ("other.sources", "URIs: https://example.invalid/\n"), + ]: + self.assertEqual((apt / "sources.list.d" / name).read_text(), contents) + self.assertEqual(sum(line.startswith("sed ") for line in log), 1) + self.assertTrue(log[-1].startswith("apt-get ") and log[-1].endswith("dbus-daemon")) + + def test_exhausted_downloads_never_reach_dpkg(self) -> None: + result, log, _ = self.install([0, 1] * 3) + self.assertEqual(result.returncode, 1) + self.assertIn("did not download in 3 attempts", result.stdout) + self.assertTrue(all(line.startswith(("timeout ", "sed ")) for line in log)) + + def test_failed_install_is_reported(self) -> None: + result, log, _ = self.install([0, 0, 100]) + self.assertEqual(result.returncode, 100) + self.assertEqual(len(log), 3) + + if __name__ == "__main__": unittest.main()