├── .actrc ├── .editorconfig ├── .gitattributes ├── .github ├── codecov.yml ├── dependabot.yml ├── tl_packages └── workflows │ ├── ci.yml │ ├── dependabot.yml │ ├── e2e-historic.yml │ ├── e2e-proxy.yml │ ├── e2e-runner.yml │ ├── e2e.yml │ ├── pull-request.yml │ └── release.yml ├── .gitignore ├── .node-version ├── .npmrc ├── LICENSE ├── README.md ├── action.yml ├── dist ├── NOTICE.md ├── index.mjs └── index.mjs.map ├── package-lock.json ├── package.json ├── packages ├── README.md ├── action │ ├── __mocks__ │ │ ├── @actions │ │ │ ├── cache.ts │ │ │ └── core.ts │ │ ├── fs │ │ │ └── promises.ts │ │ ├── os.ts │ │ ├── path.ts │ │ ├── process.ts │ │ └── unctx.ts │ ├── __snapshots__ │ │ └── cache.test.ts.snap │ ├── __tests__ │ │ ├── cache.test.ts │ │ ├── env.test.ts │ │ ├── inputs.test.ts │ │ ├── runs │ │ │ └── main │ │ │ │ ├── config.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── install.test.ts │ │ │ │ └── update.test.ts │ │ └── setup.ts │ ├── package.json │ ├── src │ │ ├── __mocks__ │ │ │ ├── cache.ts │ │ │ └── inputs.ts │ │ ├── cache.ts │ │ ├── env.ts │ │ ├── global.ts │ │ ├── index.ts │ │ ├── inputs.ts │ │ └── runs │ │ │ ├── index.ts │ │ │ ├── main │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── install.ts │ │ │ └── update.ts │ │ │ └── post.ts │ └── vitest.config.mjs ├── config │ ├── README.md │ ├── cliff.toml │ ├── dprint │ │ └── dprint.jsonc │ ├── esbuild.config.mjs │ ├── eslint.config.mjs │ ├── nunjucks │ │ ├── NOTICE.md.njk │ │ └── markdown.mjs │ ├── package.json │ ├── rspack │ │ └── plugin-licenses.mjs │ ├── tsconfig.base.json │ ├── tsdoc.json │ └── vitest │ │ ├── raw-serializer.ts │ │ ├── suppress-output.mjs │ │ └── vitest.config.mjs ├── data │ ├── .gitattributes │ ├── README.md │ ├── Taskfile.yml │ ├── data │ │ ├── package-names.json │ │ ├── texlive-versions.json │ │ ├── tlnet.json │ │ └── tlpkg-patches.json │ ├── package.json │ ├── schemas │ │ ├── package-names.schema.json │ │ ├── target.schema.json │ │ ├── texlive-versions.schema.json │ │ ├── tlnet.schema.json │ │ └── tlpkg-patches.schema.json │ ├── scripts │ │ └── generate-namemap.ts │ └── src │ │ └── index.ts ├── e2e │ ├── README.md │ ├── Taskfile.yml │ ├── __mocks__ │ │ └── @kie │ │ │ └── act-js.ts │ ├── __snapshots__ │ │ └── cache-on-failure.test.ts.snap │ ├── __tests__ │ │ └── cache-on-failure.test.ts │ ├── index.cjs │ ├── package.json │ ├── vitest.config.mjs │ └── workflows │ │ ├── cache-on-failure.yml │ │ ├── fallback-to-historic-master.yml │ │ ├── move-to-historic.yml │ │ └── tlpretest.yml ├── eslint.config.mjs ├── fixtures │ ├── .editorconfig │ ├── .gitattributes │ ├── README.md │ ├── data │ │ ├── TEXLIVE_2023.http │ │ ├── ctan-api-pkg-shellesc.json │ │ ├── ctan-api-pkg-texlive.json │ │ ├── install-tl-gpg.stderr │ │ ├── install-tl.log │ │ ├── install-tl.stderr │ │ ├── mirmon.state │ │ ├── mirrors.ctan.org.http │ │ ├── release-texlive.txt │ │ ├── texlive.2008.tlpdb │ │ ├── texlive.2023.tlpdb │ │ ├── tlmgr-install.2008.stderr │ │ ├── tlmgr-install.2009.stderr │ │ ├── tlmgr-install.2014.stderr │ │ ├── tlmgr-install.2023.stderr │ │ ├── tlmgr-repository-add.stderr │ │ ├── tlmgr-repository-list.stdout │ │ ├── tlmgr-setup_one_remotetlpdb-ctan.stderr │ │ ├── tlmgr-setup_one_remotetlpdb-tlcontrib.stderr │ │ ├── tlpkg-check_file_and_remove.stderr │ │ └── tlpkg-tlpdb_from_file.stderr │ ├── index.mjs │ ├── package.json │ └── types │ │ ├── http.d.ts │ │ └── txt.d.ts ├── logger │ ├── __mocks__ │ │ └── process.ts │ ├── __snapshots__ │ │ └── index.test.ts.snap │ ├── __tests__ │ │ └── index.test.ts │ ├── package.json │ ├── src │ │ ├── custom-inspect.ts │ │ ├── index.ts │ │ ├── log.ts │ │ ├── styles.ts │ │ └── symbols.ts │ ├── tsdoc.json │ └── vitest.config.mjs ├── polyfill │ ├── README.md │ ├── package.json │ └── src │ │ ├── array-from-async.ts │ │ ├── disposable.ts │ │ └── index.ts ├── rspack.config.mjs ├── texlive │ ├── __mocks__ │ │ ├── @actions │ │ │ ├── core.ts │ │ │ ├── exec.ts │ │ │ └── tool-cache.ts │ │ ├── fs │ │ │ └── promises.ts │ │ ├── os.ts │ │ ├── path.ts │ │ ├── process.ts │ │ └── unctx.ts │ ├── __snapshots__ │ │ └── install-tl │ │ │ └── profile.test.ts.snap │ ├── __tests__ │ │ ├── ctan.test.ts │ │ ├── install-tl │ │ │ ├── cli.test.ts │ │ │ ├── profile.test.ts │ │ │ └── texmf.test.ts │ │ ├── releases.test.ts │ │ ├── setup.ts │ │ ├── tlmgr │ │ │ ├── conf.test.ts │ │ │ ├── install.test.ts │ │ │ ├── list.test.ts │ │ │ ├── path.test.ts │ │ │ ├── pinning.test.ts │ │ │ ├── repository.test.ts │ │ │ └── update.test.ts │ │ ├── tlpdb.test.ts │ │ └── tlpkg.test.ts │ ├── package.json │ ├── src │ │ ├── __mocks__ │ │ │ ├── index.ts │ │ │ ├── releases.ts │ │ │ └── tlnet.ts │ │ ├── ctan │ │ │ ├── __mocks__ │ │ │ │ └── mirrors.ts │ │ │ ├── api.ts │ │ │ ├── index.ts │ │ │ └── mirrors.ts │ │ ├── errors.ts │ │ ├── index.ts │ │ ├── install-tl │ │ │ ├── __mocks__ │ │ │ │ └── profile.ts │ │ │ ├── cli.ts │ │ │ ├── errors.ts │ │ │ ├── index.ts │ │ │ ├── profile.ts │ │ │ └── texmf.ts │ │ ├── releases.ts │ │ ├── tex │ │ │ ├── __mocks__ │ │ │ │ └── kpse.ts │ │ │ ├── index.ts │ │ │ ├── kpse.ts │ │ │ └── texmf.ts │ │ ├── tlmgr │ │ │ ├── __mocks__ │ │ │ │ └── internals.ts │ │ │ ├── action.ts │ │ │ ├── actions │ │ │ │ ├── __mocks__ │ │ │ │ │ ├── conf.ts │ │ │ │ │ ├── list.ts │ │ │ │ │ └── update.ts │ │ │ │ ├── conf.ts │ │ │ │ ├── index.ts │ │ │ │ ├── install.ts │ │ │ │ ├── list.ts │ │ │ │ ├── path.ts │ │ │ │ ├── pinning.ts │ │ │ │ ├── repository.ts │ │ │ │ ├── update.ts │ │ │ │ └── version.ts │ │ │ ├── errors.ts │ │ │ ├── index.ts │ │ │ └── internals.ts │ │ ├── tlnet.ts │ │ ├── tlpkg │ │ │ ├── errors.ts │ │ │ ├── index.ts │ │ │ ├── patch.ts │ │ │ ├── tlpdb.ts │ │ │ └── util.ts │ │ └── version.ts │ ├── tsdoc.json │ └── vitest.config.mjs ├── tsconfig.json ├── types │ ├── README.md │ ├── package.json │ └── src │ │ ├── array-from-async.d.ts │ │ ├── class-transformer.d.ts │ │ ├── env.d.ts │ │ ├── globals.d.ts │ │ ├── index.d.ts │ │ └── node.d.ts ├── utils │ ├── README.md │ ├── __tests__ │ │ └── index.test.ts │ ├── package.json │ ├── src │ │ ├── __mocks__ │ │ │ ├── exec.ts │ │ │ ├── fs.ts │ │ │ └── index.ts │ │ ├── decorators.ts │ │ ├── exec.ts │ │ ├── fs.ts │ │ ├── http.ts │ │ ├── id.ts │ │ ├── index.ts │ │ ├── string.ts │ │ └── types.ts │ └── vitest.config.mjs └── vitest.config.mjs └── scripts ├── build.mjs ├── bump-version.sh ├── generate-matrix.jq └── run.mjs /.actrc: -------------------------------------------------------------------------------- 1 | --matrix runner:ubuntu-latest 2 | --platform ubuntu-latest=node:20.0 3 | --detect-event 4 | --env RUNNER_DEBUG=1 5 | --cache-server-path node_modules/.act 6 | --pull=false 7 | --rm 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{*.{patch,snap},dist/**/*}] 12 | charset = unset 13 | end_of_line = unset 14 | indent_size = unset 15 | indent_style = unset 16 | insert_final_newline = unset 17 | trim_trailing_whitespace = unset 18 | 19 | [*.http] 20 | end_of_line = crlf 21 | 22 | [*.sh] 23 | switch_case_indent = true 24 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | .actrc linguist-language=opts 4 | package-lock.json -diff -merge 5 | tl_packages linguist-language=ignore 6 | 7 | /dist/** -diff -merge linguist-generated 8 | 9 | *.snap text eof=lf linguist-language=javascript linguist-generated 10 | *.patch -text -merge linguist-generated 11 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | range: 50..90 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 5% 9 | patch: false 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | groups: 8 | ci: 9 | patterns: ['*'] 10 | ignore: 11 | - dependency-name: '*' 12 | update-types: 13 | - version-update:semver-minor 14 | - version-update:semver-patch 15 | commit-message: 16 | prefix: ci 17 | - package-ecosystem: npm 18 | directory: / 19 | schedule: 20 | interval: weekly 21 | day: friday 22 | time: '05:00' 23 | allow: 24 | - dependency-type: production 25 | commit-message: 26 | prefix: build 27 | include: scope 28 | -------------------------------------------------------------------------------- /.github/tl_packages: -------------------------------------------------------------------------------- 1 | # Used for E2E tests. 2 | lipsum 3 | shellesc 4 | xparse 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | workflow_call: 6 | permissions: 7 | contents: read 8 | jobs: 9 | ci: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | cache: npm 16 | node-version-file: .node-version 17 | - run: npm ci 18 | - run: npx run-s --continue-on-error check fmt-check licenses lint test 19 | - if: github.event_name == 'push' 20 | uses: codecov/codecov-action@v5 21 | with: 22 | token: ${{ secrets.CODECOV_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot 2 | on: 3 | workflow_run: 4 | workflows: ['Pull request'] 5 | types: [completed] 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | if: | 13 | github.event.workflow_run.event == 'pull_request' && 14 | github.event.workflow_run.conclusion == 'success' && 15 | github.event.sender.login == 'dependabot[bot]' 16 | steps: 17 | - name: Try to merge 18 | continue-on-error: true 19 | env: 20 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | GH_REPO: ${{ github.repository }} 22 | PR: ${{ github.event.workflow_run.pull_requests[0].number }} 23 | run: >- 24 | gh pr merge -sd "${PR}" 25 | -------------------------------------------------------------------------------- /.github/workflows/e2e-historic.yml: -------------------------------------------------------------------------------- 1 | name: E2E Historic 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | runner: 6 | description: Runner name 7 | type: string 8 | required: false 9 | texlive-version: 10 | description: TeX Live version 11 | type: string 12 | required: false 13 | permissions: 14 | contents: read 15 | jobs: 16 | generate-matrix: 17 | runs-on: ubuntu-latest 18 | outputs: 19 | matrix: ${{ steps.generate.outputs.matrix }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - if: github.actor == 'nektos/act' 23 | name: Install jq 24 | run: | 25 | apt update 26 | apt install -y --no-install-recommends jq 27 | - name: Generate matrix 28 | id: generate 29 | run: | 30 | matrix=$( 31 | ./scripts/generate-matrix.jq \ 32 | packages/data/data/texlive-versions.json 33 | ) 34 | echo "matrix=${matrix}" >>"${GITHUB_OUTPUT}" 35 | jq . <<<"${matrix}" 36 | env: 37 | runner: ${{ inputs.runner }} 38 | texlive-version: ${{ inputs.texlive-version }} 39 | historic: 40 | needs: generate-matrix 41 | strategy: 42 | matrix: 43 | include: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }} 44 | fail-fast: false 45 | max-parallel: 4 46 | runs-on: ${{ matrix.runner }} 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Setup TeX Live 50 | uses: ./ 51 | with: 52 | cache: false 53 | version: ${{ matrix.texlive-version }} 54 | -------------------------------------------------------------------------------- /.github/workflows/e2e-proxy.yml: -------------------------------------------------------------------------------- 1 | # Based on: https://github.com/actions/setup-node/blob/v4.0.0/.github/workflows/proxy.yml 2 | name: E2E Proxy 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - 'dist/**' 8 | - '!**/*.md' 9 | - action.yml 10 | workflow_dispatch: 11 | permissions: 12 | contents: read 13 | jobs: 14 | proxy: 15 | runs-on: ubuntu-latest 16 | container: 17 | image: >- 18 | ${{ 19 | github.actor == 'nektos/act' 20 | && 'node:20.0' 21 | || 'ubuntu:latest' 22 | }} 23 | options: --dns 127.0.0.1 24 | services: # Requires `act >= 0.2.53` for local run. 25 | squid-proxy: 26 | image: ubuntu/squid:latest 27 | ports: 28 | - 3128:3128 29 | env: 30 | https_proxy: http://squid-proxy:3128 31 | http_proxy: http://squid-proxy:3128 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Install requirements 35 | if: github.actor != 'nektos/act' 36 | run: | 37 | apt update 38 | apt install -y --no-install-recommends perl wget xz-utils 39 | - name: Setup TeX Live 40 | uses: ./ 41 | with: 42 | cache: false 43 | -------------------------------------------------------------------------------- /.github/workflows/e2e-runner.yml: -------------------------------------------------------------------------------- 1 | name: E2E Runner 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | runner: 6 | description: Runner name 7 | required: false 8 | type: string 9 | texlive-version: 10 | description: TeX Live version 11 | type: string 12 | required: true 13 | default: latest 14 | permissions: 15 | contents: read 16 | jobs: 17 | all-nonlatest-runners: 18 | # 19 | # Tests on GitHub hosted runners[^1] 20 | # - including runners in public preview, but 21 | # - excluding the `-latest` runners. 22 | # 23 | # The `-latest` runners are tested in a separate workflow file[^2]. 24 | # 25 | # [^1]: 26 | # [^2]: 27 | # 28 | if: ${{ !inputs.runner }} 29 | strategy: 30 | matrix: 31 | runner: 32 | - ubuntu-22.04 33 | - ubuntu-24.04-arm 34 | - ubuntu-22.04-arm 35 | - windows-2025 36 | - windows-11-arm 37 | - macos-15 38 | - macos-13 39 | fail-fast: false 40 | runs-on: ${{ matrix.runner }} 41 | name: ${{ matrix.runner }} 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Setup TeX Live 45 | uses: ./ 46 | with: 47 | cache: false 48 | version: ${{ inputs.texlive-version }} 49 | - run: tlmgr version 50 | single-runner: 51 | # 52 | # Tests on the runner specified in the input. 53 | # 54 | if: ${{ !!inputs.runner }} 55 | runs-on: ${{ inputs.runner }} 56 | name: ${{ inputs.runner }} 57 | steps: 58 | - uses: actions/checkout@v4 59 | - name: Setup TeX Live 60 | uses: ./ 61 | with: 62 | cache: false 63 | version: ${{ inputs.texlive-version }} 64 | - run: tlmgr version 65 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - 'dist/**' 7 | - '!**/*.md' 8 | - action.yml 9 | workflow_dispatch: 10 | permissions: 11 | contents: read 12 | jobs: 13 | save-cache: 14 | strategy: 15 | matrix: 16 | runner: [ubuntu-latest, windows-latest, macos-latest] 17 | fail-fast: false 18 | runs-on: ${{ matrix.runner }} 19 | outputs: 20 | ubuntu-latest: ${{ steps.status.outputs.ubuntu-latest || '' }} 21 | windows-latest: ${{ steps.status.outputs.windows-latest || '' }} 22 | macos-latest: ${{ steps.status.outputs.macos-latest || '' }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Setup TeX Live 26 | id: setup 27 | uses: ./ 28 | - name: Check that a new installation has been made 29 | if: fromJSON(steps.setup.outputs.cache-restored) 30 | run: exit 1 31 | - run: tlmgr version 32 | - name: Set output 33 | id: status 34 | shell: bash 35 | run: | 36 | echo "${MATRIX_RUNNER}=${MATRIX_RUNNER}" >> "${GITHUB_OUTPUT}" 37 | env: 38 | MATRIX_RUNNER: ${{ matrix.runner }} 39 | restore-cache: 40 | needs: save-cache 41 | if: | 42 | !cancelled() && ( 43 | needs.save-cache.outputs.ubuntu-latest || 44 | needs.save-cache.outputs.windows-latest || 45 | needs.save-cache.outputs.macos-latest 46 | ) 47 | strategy: 48 | matrix: 49 | runner: 50 | - ${{ needs.save-cache.outputs.ubuntu-latest }} 51 | - ${{ needs.save-cache.outputs.windows-latest }} 52 | - ${{ needs.save-cache.outputs.macos-latest }} 53 | exclude: 54 | - runner: '' 55 | fail-fast: false 56 | runs-on: ${{ matrix.runner }} 57 | steps: 58 | - uses: actions/checkout@v4 59 | - name: Setup TeX Live 60 | id: setup 61 | uses: ./ 62 | - name: Check that the cache is exactly matched 63 | if: ${{ !fromJSON(steps.setup.outputs.cache-hit) }} 64 | run: exit 1 65 | - run: tlmgr version 66 | delete-caches: 67 | needs: restore-cache 68 | if: >- 69 | always() && 70 | github.actor != 'nektos/act' 71 | runs-on: ubuntu-latest 72 | permissions: 73 | actions: write 74 | contents: read 75 | continue-on-error: true 76 | steps: 77 | - uses: actions/checkout@v4 78 | - name: Install package 79 | run: >- 80 | npm ci 81 | --omit=dev 82 | --engine-strict=false 83 | --ignore-scripts 84 | -w packages/e2e 85 | - uses: actions/github-script@v7 86 | with: 87 | script: | 88 | const { deleteCaches } = require('@setup-texlive-action/e2e'); 89 | await deleteCaches({ context, core, github }); 90 | compile: 91 | strategy: 92 | matrix: 93 | runner: [ubuntu-latest, windows-latest, macos-latest] 94 | fail-fast: false 95 | runs-on: ${{ matrix.runner }} 96 | steps: 97 | - uses: actions/checkout@v4 98 | - name: Setup TeX Live 99 | uses: ./ 100 | with: 101 | cache: false 102 | package-file: | 103 | **/tl_packages 104 | **/DEPENDS.txt 105 | packages: latex-bin 106 | - name: Compile 107 | shell: bash -e {0} 108 | run: | 109 | pdflatex -halt-on-error << 'EOF' 110 | \documentclass{article} 111 | \usepackage[language=english]{lipsum} 112 | \begin{document} 113 | \lipsum 114 | \end{document} 115 | EOF 116 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull request 2 | on: pull_request 3 | permissions: 4 | contents: read 5 | jobs: 6 | pull-request: 7 | uses: ./.github/workflows/ci.yml 8 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: ['v*.*.*'] 5 | permissions: 6 | contents: read 7 | jobs: 8 | check-dist: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | cache: npm 15 | node-version-file: .node-version 16 | - run: npm ci 17 | - run: npm run prepack 18 | - name: Check dist 19 | run: git diff --text --ignore-all-space --exit-code -- dist 20 | release: 21 | needs: check-dist 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: write 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | ref: ${{ github.ref_name }} 29 | - run: npm pack --ignore-scripts 30 | - name: Generate release notes 31 | run: >- 32 | git for-each-ref "${GITHUB_REF}" --format='%(contents:body)' | 33 | tee RELEASENOTES.md 34 | - name: Release 35 | env: 36 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | GH_REPO: ${{ github.repository }} 38 | run: >- 39 | gh release create --draft -F RELEASENOTES.md -- "${GITHUB_REF_NAME}" *.tgz 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | lib 3 | node_modules 4 | *.tgz 5 | *.tsbuildinfo 6 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.11 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | git-tag-version=false 3 | init-version=0.0.0 4 | init-type=module 5 | scope=@setup-texlive-action 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 @teatimeguest 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Setup TeXLive Action 2 | description: A GitHub Action to set up TeX Live. 3 | author: '@teatimeguest' 4 | inputs: 5 | cache: 6 | description: >- 7 | Enable caching for `TEXDIR`. 8 | default: 'true' 9 | required: false 10 | package-file: 11 | description: >- 12 | Glob patterns for specifying files 13 | containing the names of TeX packages to be installed. 14 | The file format should be the same as the syntax for the `packages` input. 15 | The `DEPENDS.txt` format is also supported. 16 | default: '' 17 | required: false 18 | packages: 19 | description: >- 20 | Specify the names of TeX packages to install, separated by whitespaces. 21 | Schemes and collections are also acceptable. 22 | Everything after `#` will be treated as a comment. 23 | default: '' 24 | required: false 25 | prefix: 26 | description: >- 27 | TeX Live installation prefix. 28 | This has the same effect as `TEXLIVE_INSTALL_PREFIX`. 29 | Defaults to `$RUNNER_TEMP/setup-texlive-action`. 30 | required: false 31 | repository: 32 | description: >- 33 | Specify the package repository to be used as the main repository. 34 | Currently only http(s) repositories are supported. 35 | required: false 36 | texdir: 37 | description: >- 38 | TeX Live system installation directory. 39 | This has the same effect as the installer's `-texdir` option and 40 | takes precedence 41 | over the `prefix` input and related environment variables. 42 | required: false 43 | tlcontrib: 44 | description: >- 45 | Set up TLContrib as an additional TeX package repository. 46 | This input will be ignored for older versions. 47 | default: 'false' 48 | required: false 49 | update-all-packages: 50 | description: >- 51 | Update all TeX packages when cache restored. 52 | The default is `false` and the action will update only `tlmgr`. 53 | default: 'false' 54 | required: false 55 | version: 56 | description: >- 57 | TeX Live version to install. 58 | Supported values are `2008` to `2025`, and `latest`. 59 | required: false 60 | outputs: 61 | cache-hit: 62 | description: >- 63 | A boolean value to indicate if an exact match cache was found. 64 | cache-restored: 65 | description: >- 66 | A boolean value to indicate if a cache was found. 67 | version: 68 | description: The installed TeX Live version. 69 | runs: 70 | using: node20 71 | main: dist/index.mjs 72 | post: dist/index.mjs 73 | # - Use `fromJSON` since there are no array literals. 74 | # - Use `toJSON` for strict value comparison. 75 | post-if: >- 76 | contains( 77 | fromJSON('["null", "\"0\""]'), 78 | toJSON(env.SETUP_TEXLIVE_ACTION_NO_CACHE_ON_FAILURE) 79 | ) 80 | && !cancelled() 81 | || success() 82 | branding: 83 | color: green 84 | icon: type 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "setup-texlive-action", 3 | "private": true, 4 | "version": "3.4.1", 5 | "description": "A GitHub Action to set up TeX Live", 6 | "scripts": { 7 | "build": "node scripts/build.mjs", 8 | "changelog": "git cliff -c packages/config/cliff.toml -u -s all", 9 | "check": "tsc -p packages/tsconfig.json --noEmit", 10 | "dprint": "dprint -c packages/config/dprint/dprint.jsonc", 11 | "e2e": "npm -w packages/e2e run e2e --", 12 | "fmt": "npm run dprint fmt", 13 | "fmt-check": "run-p --aggregate-output -c 'dprint check' fmt-check:ec", 14 | "fmt-check:ec": "git ls-files -z | xargs -0 ec", 15 | "licenses": "cd packages && rspack", 16 | "link-check": "markdown-link-check README.md", 17 | "lint": "cd packages && eslint .", 18 | "test": "cd packages && vitest", 19 | "prepack": "run-p --aggregate-output -c build licenses", 20 | "preversion": "npm ci && npm run prepack", 21 | "version": "bash scripts/bump-version.sh", 22 | "postversion": "bash scripts/bump-version.sh" 23 | }, 24 | "author": "@teatimeguest", 25 | "license": "MIT", 26 | "type": "module", 27 | "main": "dist/index.mjs", 28 | "engines": { 29 | "node": ">=20" 30 | }, 31 | "files": [ 32 | "action.yml", 33 | "dist" 34 | ], 35 | "exports": { 36 | "./package.json": "./package.json" 37 | }, 38 | "workspaces": [ 39 | "packages/*" 40 | ], 41 | "dependencies": { 42 | "semver": "^7.7.2" 43 | }, 44 | "devDependencies": { 45 | "@go-task/cli": "^3.42.1", 46 | "@rspack/cli": "~1.3.5", 47 | "@types/mock-fs": "^4.13.4", 48 | "@types/node": "20.*", 49 | "@types/semver": "^7.7.0", 50 | "better-typescript-lib": "^2.11.0", 51 | "dprint": "^0.49.1", 52 | "editorconfig-checker": "^6.0.1", 53 | "esbuild": "^0.25.2", 54 | "eslint": "^9.24.0", 55 | "git-cliff": "^2.8.0", 56 | "markdown-link-check": "^3.13.7", 57 | "mock-fs": "^5.5.0", 58 | "npm-run-all2": "^7.0.2", 59 | "patch-package": "^8.0.0", 60 | "taze": "^19.0.4", 61 | "ts-dedent": "^2.2.0", 62 | "ts-essentials": "^10.0.4", 63 | "tsx": "^4.19.3", 64 | "typescript": "^5.8.3", 65 | "vitest": "^3.1.1" 66 | }, 67 | "overrides": { 68 | "@rspack/core": "1.1.*", 69 | "ajv-cli": { 70 | "fast-json-patch": "^3.1.1" 71 | }, 72 | "rimraf@<=3": "^6.0.1", 73 | "semver": "$semver" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/README.md: -------------------------------------------------------------------------------- 1 | # Contents 2 | 3 | - [@setup-texlive-action/action](./action) – 4 | Main source files. 5 | - [@setup-texlive-action/config](./config) – 6 | Internal configuration files. 7 | - [@setup-texlive-action/data](./data) – 8 | Static data files on something around TeX Live. 9 | - [@setup-texlive-action/e2e](./e2e) – 10 | E2E test files and helper scripts. 11 | - [@setup-texlive-action/fixtures](./fixtures) – 12 | Unit test fixtures and Vitest plugin. 13 | - [@setup-texlive-action/logger](./logger) – 14 | Logger module. 15 | - [@setup-texlive-action/polyfill](./polyfill) – 16 | Polyfills for some [ECMAScript proposals](https://www.proposals.es). 17 | - [@setup-texlive-action/texlive](./texlive) – 18 | Module for TeX Live-related utilities. 19 | - [@types/setup-texlive-action](./types) – 20 | Global type definitions for third-party resources. 21 | - [@setup-texlive-action/utils](./utils) – 22 | Module for common utilities. 23 | -------------------------------------------------------------------------------- /packages/action/__mocks__/@actions/cache.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const { ReserveCacheError } = await vi.importActual< 4 | typeof import('@actions/cache') 5 | >('@actions/cache'); 6 | export const saveCache = vi.fn().mockResolvedValue(1); 7 | export const restoreCache = vi.fn(); 8 | export const isFeatureAvailable = vi.fn().mockReturnValue(true); 9 | -------------------------------------------------------------------------------- /packages/action/__mocks__/@actions/core.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | const actual = await vi.importActual( 4 | '@actions/core', 5 | ); 6 | 7 | export const addPath = vi.fn(); 8 | export const debug = vi.fn(); 9 | export const error = vi.fn(); 10 | export const exportVariable = vi.fn(); 11 | export const getBooleanInput = vi.fn(actual.getBooleanInput); 12 | export const getInput = vi.fn(actual.getInput); 13 | export const getState = vi.fn().mockResolvedValue(''); 14 | export const group = vi.fn(async (name, fn) => await fn()); 15 | export const info = vi.fn(); 16 | export const isDebug = vi.fn().mockReturnValue(false); 17 | export const notice = vi.fn(); 18 | export const saveState = vi.fn(); 19 | export const setFailed = vi.fn((error) => { 20 | throw new Error(`${error}`); 21 | }); 22 | export const setOutput = vi.fn(); 23 | export const warning = vi.fn(); 24 | -------------------------------------------------------------------------------- /packages/action/__mocks__/fs/promises.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const readFile = vi.fn().mockResolvedValue(''); 4 | export const readdir = vi.fn().mockResolvedValue(['']); 5 | export const writeFile = vi.fn(); 6 | -------------------------------------------------------------------------------- /packages/action/__mocks__/os.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const EOL = '\n'; 4 | export const arch = vi.fn().mockReturnValue(''); 5 | export const homedir = vi.fn().mockReturnValue('~'); 6 | export const platform = vi.fn().mockReturnValue('linux'); 7 | export const tmpdir = vi.fn().mockReturnValue(''); 8 | -------------------------------------------------------------------------------- /packages/action/__mocks__/path.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | import * as os from 'node:os'; 4 | 5 | export const { posix, win32 } = await vi.importActual< 6 | typeof import('node:path') 7 | >('node:path'); 8 | 9 | function getPath(): any { 10 | return os.platform() === 'win32' ? win32 : posix; 11 | } 12 | 13 | export const basename = vi.fn((...args) => getPath().basename(...args)); 14 | export const format = vi.fn((...args) => getPath().format(...args)); 15 | export const join = vi.fn((...args) => getPath().join(...args)); 16 | export const normalize = vi.fn((...args) => getPath().normalize(...args)); 17 | export const resolve = vi.fn((...args) => getPath().resolve(...args)); 18 | -------------------------------------------------------------------------------- /packages/action/__mocks__/process.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, vi } from 'vitest'; 2 | 3 | beforeEach(() => { 4 | for (const key of Object.keys(globalThis.process.env)) { 5 | delete globalThis.process.env[key]; 6 | } 7 | vi.stubEnv('RUNNER_TEMP', ''); 8 | vi.stubEnv('RUNNER_DEBUG', '0'); 9 | }); 10 | 11 | export const { env } = globalThis.process; 12 | export const stdout = { 13 | hasColors: vi.fn().mockResolvedValue(false), 14 | }; 15 | export default { env, stdout }; 16 | -------------------------------------------------------------------------------- /packages/action/__mocks__/unctx.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, vi } from 'vitest'; 2 | 3 | import type { UseContext } from 'unctx'; 4 | 5 | const unctx = await vi.importActual('unctx'); 6 | 7 | export function createContext(): UseContext { 8 | const ctx = unctx.createContext(); 9 | afterEach(ctx.unset); 10 | return ctx; 11 | } 12 | -------------------------------------------------------------------------------- /packages/action/__snapshots__/cache.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ActionsCacheService > @@dispose > does not set \`target\` (primary) 1`] = ` 4 | [ 5 | "CACHE", 6 | { 7 | "key": "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", 8 | }, 9 | ] 10 | `; 11 | 12 | exports[`ActionsCacheService > @@dispose > does not set \`target\` (unique) 1`] = ` 13 | [ 14 | "CACHE", 15 | { 16 | "key": "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945-", 17 | }, 18 | ] 19 | `; 20 | 21 | exports[`ActionsCacheService > @@dispose > sets \`target\` (fail) 1`] = ` 22 | [ 23 | "CACHE", 24 | { 25 | "key": "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", 26 | "target": "", 27 | }, 28 | ] 29 | `; 30 | 31 | exports[`ActionsCacheService > @@dispose > sets \`target\` (none) 1`] = ` 32 | [ 33 | "CACHE", 34 | { 35 | "key": "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", 36 | "target": "", 37 | }, 38 | ] 39 | `; 40 | 41 | exports[`ActionsCacheService > @@dispose > sets \`target\` (oldprimary) 1`] = ` 42 | [ 43 | "CACHE", 44 | { 45 | "key": "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945-", 46 | "target": "", 47 | }, 48 | ] 49 | `; 50 | 51 | exports[`ActionsCacheService > @@dispose > sets \`target\` (oldsecondary) 1`] = ` 52 | [ 53 | "CACHE", 54 | { 55 | "key": "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", 56 | "target": "", 57 | }, 58 | ] 59 | `; 60 | 61 | exports[`ActionsCacheService > @@dispose > sets \`target\` (oldunique) 1`] = ` 62 | [ 63 | "CACHE", 64 | { 65 | "key": "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945-}", 66 | }, 67 | ] 68 | `; 69 | 70 | exports[`ActionsCacheService > @@dispose > sets \`target\` (secondary) 1`] = ` 71 | [ 72 | "CACHE", 73 | { 74 | "key": "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", 75 | "target": "", 76 | }, 77 | ] 78 | `; 79 | 80 | exports[`ActionsCacheService > restore > restores cache 1`] = ` 81 | [ 82 | [ 83 | "", 84 | ], 85 | "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945-", 86 | [ 87 | "setup-texlive-action-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", 88 | "setup-texlive-linux--2025-4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", 89 | "setup-texlive-action-linux--2025-", 90 | "setup-texlive-linux--2025-", 91 | ], 92 | ] 93 | `; 94 | 95 | exports[`ActionsCacheService > restore > restores cache 2`] = ` 96 | [ 97 | [ 98 | "", 99 | ], 100 | "setup-texlive-action-linux--2025-cc603f9d1b6fb8568f57853e4936e2604c5eb12ad31ad1908a08b32a04e38656-", 101 | [ 102 | "setup-texlive-action-linux--2025-cc603f9d1b6fb8568f57853e4936e2604c5eb12ad31ad1908a08b32a04e38656", 103 | "setup-texlive-linux--2025-cc603f9d1b6fb8568f57853e4936e2604c5eb12ad31ad1908a08b32a04e38656", 104 | "setup-texlive-action-linux--2025-", 105 | "setup-texlive-linux--2025-", 106 | ], 107 | ] 108 | `; 109 | -------------------------------------------------------------------------------- /packages/action/__tests__/env.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { env } from 'node:process'; 4 | 5 | import * as core from '@actions/core'; 6 | 7 | import { init } from '#action/env'; 8 | 9 | vi.unmock('#action/env'); 10 | 11 | describe('init', () => { 12 | it('sets some default values', () => { 13 | init(); 14 | expect(env).toMatchObject({ 15 | TEXLIVE_INSTALL_ENV_NOCHECK: '1', 16 | TEXLIVE_INSTALL_NO_WELCOME: '1', 17 | }); 18 | }); 19 | 20 | it('ignores some environment variables', () => { 21 | vi.stubEnv('TEXLIVE_INSTALL_TEXDIR', ''); 22 | init(); 23 | expect(env).not.toHaveProperty('TEXLIVE_INSTALL_TEXDIR'); 24 | expect(core.warning).toHaveBeenCalledOnce(); 25 | expect(vi.mocked(core.warning).mock.calls[0]?.[0]).toMatchInlineSnapshot( 26 | '"`TEXLIVE_INSTALL_TEXDIR` is set, but ignored"', 27 | ); 28 | }); 29 | 30 | it('favors user settings over default values', () => { 31 | vi.stubEnv('TEXLIVE_INSTALL_PREFIX', ''); 32 | vi.stubEnv('NOPERLDOC', 'true'); 33 | init(); 34 | expect(env).toMatchObject({ 35 | TEXLIVE_INSTALL_PREFIX: '', 36 | NOPERLDOC: 'true', 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /packages/action/__tests__/runs/main/install.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { 4 | type InstallTL, 5 | InstallTLError, 6 | Profile, 7 | TlpdbError, 8 | acquire, 9 | } from '@setup-texlive-action/texlive'; 10 | 11 | import { install } from '#action/runs/main/install'; 12 | 13 | vi.unmock('#action/runs/main/install'); 14 | 15 | vi.mocked(acquire).mockResolvedValue({ run: vi.fn() } as unknown as InstallTL); 16 | 17 | const downloadErrors = [ 18 | new InstallTLError('', { code: InstallTLError.Code.FAILED_TO_DOWNLOAD }), 19 | new InstallTLError('', { code: InstallTLError.Code.UNEXPECTED_VERSION }), 20 | ] as const; 21 | 22 | const installErrors = [ 23 | new InstallTLError('', { 24 | code: InstallTLError.Code.INCOMPATIBLE_REPOSITORY_VERSION, 25 | }), 26 | new TlpdbError('', { code: TlpdbError.Code.FAILED_TO_INITIALIZE }), 27 | ] as const; 28 | 29 | const errors = [...downloadErrors, ...installErrors] as const; 30 | 31 | describe('fallback to master', () => { 32 | it.each(downloadErrors)('if failed to download', async (error) => { 33 | vi.mocked(acquire).mockRejectedValueOnce(error); 34 | const profile = new Profile(LATEST_VERSION, { prefix: '' }); 35 | await expect(install({ profile })).resolves.not.toThrow(); 36 | }); 37 | 38 | it.each(installErrors)('if failed to install', async (error) => { 39 | vi.mocked(acquire).mockResolvedValueOnce({ 40 | run: vi.fn().mockRejectedValueOnce(error), 41 | } as unknown as InstallTL); 42 | const profile = new Profile(LATEST_VERSION, { prefix: '' }); 43 | await expect(install({ profile })).resolves.not.toThrow(); 44 | }); 45 | }); 46 | 47 | it.each(errors)('does not fallback for older versions', async (error) => { 48 | vi.mocked(acquire).mockRejectedValueOnce(error); 49 | const profile = new Profile('2021', { prefix: '' }); 50 | await expect(install({ profile })).rejects.toThrow(error); 51 | }); 52 | 53 | it.each(errors)('does not fallback if repository set', async (error) => { 54 | vi.mocked(acquire).mockRejectedValueOnce(error); 55 | const profile = new Profile(LATEST_VERSION, { prefix: '' }); 56 | const repository = new URL(MOCK_URL); 57 | await expect(install({ profile, repository })).rejects.toThrow(error); 58 | }); 59 | -------------------------------------------------------------------------------- /packages/action/__tests__/runs/main/update.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, expect, it, vi } from 'vitest'; 2 | 3 | import { 4 | Tlmgr, 5 | TlmgrError, 6 | TlpdbError, 7 | tlmgr, 8 | } from '@setup-texlive-action/texlive'; 9 | 10 | import { CacheService } from '#action/cache'; 11 | import { update } from '#action/runs/main/update'; 12 | 13 | const { repository: { list, remove } } = tlmgr; 14 | 15 | const updateCache = vi.fn(); 16 | 17 | vi.unmock('#action/runs/main/update'); 18 | 19 | vi.mocked(list).mockImplementation(async function*() {}); 20 | vi.mocked(CacheService.use).mockReturnValue( 21 | { update: updateCache } as unknown as CacheService, 22 | ); 23 | 24 | beforeEach(() => { 25 | Tlmgr.setup({ version: LATEST_VERSION, TEXDIR: '' }); 26 | }); 27 | 28 | const versionOutdated = new TlmgrError('', { 29 | action: 'update', 30 | code: TlmgrError.Code.TL_VERSION_OUTDATED, 31 | }); 32 | 33 | const failedToInitialize = new TlpdbError('', { 34 | code: TlpdbError.Code.FAILED_TO_INITIALIZE, 35 | }); 36 | 37 | it('move to historic', async () => { 38 | vi.mocked(tlmgr.update).mockRejectedValueOnce(versionOutdated); 39 | const opts = { version: LATEST_VERSION }; 40 | await expect(update(opts)).resolves.not.toThrow(); 41 | expect(updateCache).toHaveBeenCalled(); 42 | }); 43 | 44 | it('move to historic master', async () => { 45 | vi 46 | .mocked(tlmgr.update) 47 | .mockRejectedValueOnce(versionOutdated) 48 | .mockRejectedValueOnce(failedToInitialize); 49 | const opts = { version: LATEST_VERSION }; 50 | await expect(update(opts)).resolves.not.toThrow(); 51 | expect(updateCache).toHaveBeenCalled(); 52 | }); 53 | 54 | it('does not move to historic if repository set', async () => { 55 | vi.mocked(tlmgr.update).mockRejectedValueOnce(versionOutdated); 56 | const opts = { version: LATEST_VERSION, repository: new URL(MOCK_URL) }; 57 | await expect(update(opts)).rejects.toThrow(versionOutdated); 58 | }); 59 | 60 | it('removes tlcontrib', async () => { 61 | vi.mocked(list).mockImplementationOnce(async function*() { 62 | yield { tag: 'main', path: MOCK_URL }; 63 | yield { tag: 'tlcontrib', path: MOCK_URL }; 64 | }); 65 | const opts = { version: '2024' } as const; 66 | await expect(update(opts)).resolves.not.toThrow(); 67 | expect(remove).toHaveBeenCalledWith('tlcontrib'); 68 | }); 69 | 70 | it('removes tlpretest', async () => { 71 | vi.mocked(list).mockImplementationOnce(async function*() { 72 | yield { tag: 'main', path: 'https://example.com/path/to/tlpretest/' }; 73 | yield { tag: 'tlcontrib', path: MOCK_URL }; 74 | }); 75 | const opts = { version: LATEST_VERSION }; 76 | await expect(update(opts)).resolves.not.toThrow(); 77 | expect(remove).toHaveBeenCalledWith('main'); 78 | }); 79 | 80 | it('defaults to update only tlmgr', async () => { 81 | await expect(update({ version: LATEST_VERSION })) 82 | .resolves 83 | .not 84 | .toThrow(); 85 | expect(tlmgr.update).toHaveBeenCalledWith({ 86 | self: true, 87 | all: false, 88 | reinstallForciblyRemoved: false, 89 | }); 90 | }); 91 | 92 | it('calls `tlmgr update --all` if `updateAllPackages: true`', async () => { 93 | await expect(update({ version: LATEST_VERSION, updateAllPackages: true })) 94 | .resolves 95 | .not 96 | .toThrow(); 97 | expect(tlmgr.update).toHaveBeenCalledWith({ 98 | self: true, 99 | all: true, 100 | reinstallForciblyRemoved: true, 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /packages/action/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | import '#action/global'; 4 | 5 | import versions from '@setup-texlive-action/data/texlive-versions.json' with { 6 | type: 'json', 7 | }; 8 | import type { Version } from '@setup-texlive-action/texlive'; 9 | 10 | vi.mock('fs/promises'); 11 | vi.mock('os'); 12 | vi.mock('path'); 13 | vi.mock('process'); 14 | vi.mock('@actions/cache'); 15 | vi.mock('@actions/core'); 16 | vi.mock('@actions/glob'); 17 | vi.mock('@actions/http-client'); 18 | vi.mock('@setup-texlive-action/texlive'); 19 | vi.mock('@setup-texlive-action/utils'); 20 | vi.mock('source-map-support/register'); 21 | vi.mock('unctx'); 22 | vi.mock('#action/cache'); 23 | vi.mock('#action/env'); 24 | vi.mock('#action/inputs'); 25 | vi.mock('#action/runs/main/config'); 26 | vi.mock('#action/runs/main/install'); 27 | vi.mock('#action/runs/main/update'); 28 | 29 | vi.stubGlobal('LATEST_VERSION', versions.current.version as Version); 30 | // https://www.rfc-editor.org/rfc/rfc2606.html 31 | vi.stubGlobal('MOCK_URL', 'https://example.com/'); 32 | 33 | declare global { 34 | var LATEST_VERSION: Version; 35 | var MOCK_URL: string; 36 | } 37 | 38 | /* eslint no-var: off */ 39 | -------------------------------------------------------------------------------- /packages/action/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setup-texlive-action/action", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "main": "src/index.ts", 7 | "scripts": { 8 | "test": "vitest" 9 | }, 10 | "sideEffects": [ 11 | "./src/global.ts" 12 | ], 13 | "dependencies": { 14 | "@actions/cache": "^4.0.3", 15 | "@actions/core": "^1.11.0", 16 | "@actions/glob": "^0.5.0", 17 | "@actions/http-client": "^2.2.3", 18 | "class-transformer": "^0.5.1", 19 | "depends-txt": "^0.1.0", 20 | "source-map-support": "^0.5.21", 21 | "ts-pattern": "^5.7.1", 22 | "tslib": "^2.8.1", 23 | "unctx": "^2.4.1" 24 | }, 25 | "devDependencies": { 26 | "@setup-texlive-action/data": "*", 27 | "@setup-texlive-action/logger": "*", 28 | "@setup-texlive-action/polyfill": "*", 29 | "@setup-texlive-action/texlive": "*", 30 | "@setup-texlive-action/utils": "*" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/action/src/__mocks__/cache.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | import { createContext } from 'unctx'; 4 | 5 | import type { CacheEntryConfig, CacheServiceConfig } from '#action/cache'; 6 | 7 | export const { CacheService, DefaultCacheService } = await vi.importActual< 8 | typeof import('#action/cache') 9 | >('#action/cache'); 10 | 11 | vi.spyOn(DefaultCacheService.prototype, 'restore'); 12 | vi.spyOn(DefaultCacheService.prototype, 'update'); 13 | vi.spyOn(DefaultCacheService.prototype, 'register'); 14 | vi.spyOn(DefaultCacheService.prototype, Symbol.dispose); 15 | 16 | const ctx = createContext>(); 17 | 18 | vi.spyOn(CacheService, 'setup').mockImplementation( 19 | (_: CacheEntryConfig, config?: CacheServiceConfig) => { 20 | const service = new DefaultCacheService(); 21 | (service as Writable).enabled = config?.enable ?? true; 22 | ctx.set(service); 23 | return service; 24 | }, 25 | ); 26 | vi.spyOn(CacheService, 'use'); 27 | 28 | export const save = vi.fn().mockResolvedValue(undefined); 29 | -------------------------------------------------------------------------------- /packages/action/src/__mocks__/inputs.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const getCache = vi.fn().mockReturnValue(true); 4 | export const getPackageFile = vi.fn(); 5 | export const getPackages = vi.fn(); 6 | export const getPrefix = vi.fn().mockReturnValue(''); 7 | export const getRepository = vi.fn(); 8 | export const getTexdir = vi.fn(); 9 | export const getTlcontrib = vi.fn().mockReturnValue(false); 10 | export const getUpdateAllPackages = vi.fn().mockReturnValue(false); 11 | export const getVersion = vi.fn(); 12 | -------------------------------------------------------------------------------- /packages/action/src/env.ts: -------------------------------------------------------------------------------- 1 | import { tmpdir } from 'node:os'; 2 | import { env } from 'node:process'; 3 | 4 | import * as log from '@setup-texlive-action/logger'; 5 | import { Texmf } from '@setup-texlive-action/texlive'; 6 | 7 | export function init(): void { 8 | if (!('RUNNER_TEMP' in env)) { 9 | log.warn('`RUNNER_TEMP` not defined, %s will be used instead', tmpdir()); 10 | env.RUNNER_TEMP = tmpdir(); 11 | } 12 | // Use RUNNER_TEMP as a temporary directory during setup. 13 | env['TMPDIR'] = env.RUNNER_TEMP; 14 | 15 | env.TEXLIVE_INSTALL_ENV_NOCHECK ??= '1'; 16 | env.TEXLIVE_INSTALL_NO_WELCOME ??= '1'; 17 | 18 | for (const tree of Texmf.SYSTEM_TREES) { 19 | const key = `TEXLIVE_INSTALL_${tree}`; 20 | if (tree !== 'TEXMFLOCAL' && key in env) { 21 | log.warn('`%s` is set, but ignored', key); 22 | delete env[key]; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/action/src/global.ts: -------------------------------------------------------------------------------- 1 | import { setDefaultResultOrder } from 'node:dns'; 2 | import { setDefaultAutoSelectFamily } from 'node:net'; 3 | 4 | import '@setup-texlive-action/logger/custom-inspect'; 5 | import '@setup-texlive-action/polyfill'; 6 | import 'source-map-support/register'; 7 | 8 | // https://github.com/node-fetch/node-fetch/issues/1624 9 | // https://github.com/nodejs/node/issues/47822 10 | setDefaultResultOrder('ipv4first'); 11 | setDefaultAutoSelectFamily(false); 12 | -------------------------------------------------------------------------------- /packages/action/src/index.ts: -------------------------------------------------------------------------------- 1 | import '#action/global'; 2 | import run from '#action/runs'; 3 | 4 | await run(); 5 | -------------------------------------------------------------------------------- /packages/action/src/inputs.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { env } from 'node:process'; 3 | 4 | import { getBooleanInput, getInput } from '@actions/core'; 5 | import id from '@setup-texlive-action/utils/id'; 6 | 7 | export function getCache(): boolean { 8 | return getBoolean('cache'); 9 | } 10 | 11 | export function getPackageFile(): string | undefined { 12 | return getString('package-file'); 13 | } 14 | 15 | export function getPackages(): string | undefined { 16 | return getString('packages'); 17 | } 18 | 19 | export function getPrefix(): string { 20 | let input = getString('prefix'); 21 | input ??= env.TEXLIVE_INSTALL_PREFIX; 22 | input ??= path.join(env.RUNNER_TEMP!, id['kebab-case']); 23 | return path.normalize(input); 24 | } 25 | 26 | export function getRepository(): URL | undefined { 27 | const input = getInput('repository'); 28 | if (input.length === 0) { 29 | return undefined; 30 | } 31 | let url: URL; 32 | try { 33 | url = new URL(input); 34 | } catch (cause) { 35 | const error = new TypeError('Invalid input for `repository`', { cause }); 36 | error['input'] = input; 37 | throw error; 38 | } 39 | if (!['http:', 'https:'].includes(url.protocol)) { 40 | const error = new TypeError( 41 | 'Currently only http/https repositories are supported', 42 | ); 43 | error['repository'] = url; 44 | throw error; 45 | } 46 | // Normalize url 47 | url.pathname = path.posix.join( 48 | path 49 | .posix 50 | .normalize(url.pathname) 51 | // See `only_load_remote` in `install-tl`: 52 | .replace(/\/archive\/?|\/tlpkg(?:\/(?:texlive\.tlpdb)?)?$/v, ''), 53 | path.posix.sep, 54 | ); 55 | return url; 56 | } 57 | 58 | export function getTexdir(): string | undefined { 59 | const input = getInput('texdir'); 60 | return input.length === 0 ? undefined : path.normalize(input); 61 | } 62 | 63 | export function getTlcontrib(): boolean { 64 | return getBoolean('tlcontrib'); 65 | } 66 | 67 | export function getUpdateAllPackages(): boolean { 68 | return getBoolean('update-all-packages'); 69 | } 70 | 71 | export function getVersion(): string | undefined { 72 | return getString('version')?.trim().toLowerCase(); 73 | } 74 | 75 | function getString(name: string): string | undefined { 76 | const input = getInput(name); 77 | return input.length === 0 ? undefined : input; 78 | } 79 | 80 | function getBoolean(name: string): boolean { 81 | try { 82 | return getBooleanInput(name); 83 | } catch (cause) { 84 | throw new Error(`Invalid input for \`${name}\``, { cause }); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /packages/action/src/runs/index.ts: -------------------------------------------------------------------------------- 1 | import { getState, saveState } from '@actions/core'; 2 | import * as log from '@setup-texlive-action/logger'; 3 | 4 | import { main } from '#action/runs/main'; 5 | import { post } from '#action/runs/post'; 6 | 7 | export default async function run(): Promise { 8 | const state = 'POST'; 9 | try { 10 | if (getState(state) === '') { 11 | saveState(state, '1'); 12 | await main(); 13 | } else { 14 | await post(); 15 | } 16 | } catch (error) { 17 | log.fatal({ error }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/action/src/runs/main/index.ts: -------------------------------------------------------------------------------- 1 | import { setOutput } from '@actions/core'; 2 | import * as log from '@setup-texlive-action/logger'; 3 | import { 4 | Profile, 5 | ReleaseData, 6 | Tlmgr, 7 | tlnet, 8 | } from '@setup-texlive-action/texlive'; 9 | 10 | import { CacheService } from '#action/cache'; 11 | import { Config } from '#action/runs/main/config'; 12 | import { install } from '#action/runs/main/install'; 13 | import { adjustTexmf, update } from '#action/runs/main/update'; 14 | 15 | export async function main(): Promise { 16 | const config = await Config.load(); 17 | const { latest, previous } = ReleaseData.use(); 18 | await using profile = new Profile(config.version, config); 19 | 20 | using cache = CacheService.setup({ 21 | TEXDIR: profile.TEXDIR, 22 | packages: config.packages, 23 | version: config.version, 24 | }, { 25 | enable: config.cache, 26 | }); 27 | 28 | if (cache.enabled) { 29 | await log.group('Restoring cache', async () => { 30 | await cache.restore(); 31 | }); 32 | } 33 | 34 | if (!cache.restored) { 35 | await log.group('Installation profile', async () => { 36 | log.info(profile.toString()); 37 | }); 38 | await log.group('Installing TeX Live', async () => { 39 | await install({ profile, repository: config.repository }); 40 | }); 41 | } 42 | 43 | const tlmgr = Tlmgr.setup(profile); 44 | await tlmgr.path.add(); 45 | 46 | if (cache.restored) { 47 | if (profile.version >= previous.version) { 48 | const msg = config.updateAllPackages 49 | ? 'Updating packages' 50 | : profile.version >= latest.version 51 | ? 'Updating tlmgr' 52 | : 'Checking the package repository status'; 53 | await log.group(msg, async () => { 54 | await update(config); 55 | }); 56 | } 57 | await adjustTexmf(profile); 58 | } 59 | 60 | if (config.tlcontrib) { 61 | await log.group('Setting up TLContrib', async () => { 62 | await tlmgr.repository.add(await tlnet.contrib(), 'tlcontrib'); 63 | await tlmgr.pinning.add('tlcontrib', '*'); 64 | }); 65 | } 66 | 67 | if (!cache.hit && config.packages.size > 0) { 68 | await log.group('Installing packages', async () => { 69 | await tlmgr.install(config.packages); 70 | }); 71 | } 72 | 73 | await log.group('TeX Live environment details', async () => { 74 | await tlmgr.version(); 75 | log.info('Package versions:'); 76 | for await (const { name, revision, cataloguedata } of tlmgr.list()) { 77 | log.info(' %s: %s', name, cataloguedata?.version ?? `rev${revision}`); 78 | } 79 | }); 80 | 81 | cache.register(); 82 | setOutput('version', config.version); 83 | } 84 | -------------------------------------------------------------------------------- /packages/action/src/runs/main/install.ts: -------------------------------------------------------------------------------- 1 | import data from '@setup-texlive-action/data/tlnet.json' with { type: 'json' }; 2 | import * as log from '@setup-texlive-action/logger'; 3 | import { 4 | type InstallTL, 5 | InstallTLError, 6 | type Profile, 7 | ReleaseData, 8 | TlpdbError, 9 | type Version, 10 | acquire, 11 | tlnet, 12 | } from '@setup-texlive-action/texlive'; 13 | import { P, match } from 'ts-pattern'; 14 | 15 | export interface InstallOptions { 16 | profile: Profile; 17 | repository?: Readonly | undefined; 18 | } 19 | 20 | export async function install( 21 | options: Readonly, 22 | ): Promise { 23 | await new Installer(options).run(); 24 | } 25 | 26 | class Installer { 27 | private readonly maxRetries: 0 | 1 = 0; 28 | private try: number = 1; 29 | private installTL: InstallTL | undefined; 30 | 31 | constructor(private options: Readonly) { 32 | if ( 33 | this.options.repository === undefined 34 | && this.version >= ReleaseData.use().previous.version 35 | ) { 36 | this.maxRetries = 1; 37 | } 38 | } 39 | 40 | async run(): Promise { 41 | for (; this.try <= this.maxRetries + 1; ++this.try) { 42 | try { 43 | await this.tryWith(await this.pickRepository()); 44 | return; 45 | } catch (error) { 46 | if ( 47 | this.try <= this.maxRetries 48 | && match(error) 49 | .with( 50 | P.instanceOf(InstallTLError), 51 | { 52 | code: P.union( 53 | InstallTLError.Code.FAILED_TO_DOWNLOAD, 54 | InstallTLError.Code.UNEXPECTED_VERSION, 55 | InstallTLError.Code.INCOMPATIBLE_REPOSITORY_VERSION, 56 | ), 57 | }, 58 | () => true, 59 | ) 60 | .with( 61 | P.instanceOf(TlpdbError), 62 | { code: TlpdbError.Code.FAILED_TO_INITIALIZE }, 63 | () => true, 64 | ) 65 | .otherwise(() => false) 66 | ) { 67 | log.info({ error }); 68 | continue; 69 | } 70 | throw error; 71 | } 72 | } 73 | } 74 | 75 | private async tryWith(repository: Readonly): Promise { 76 | if (this.try > 1) { 77 | log.info('Switched to repository: %s', repository); 78 | } else { 79 | log.info('Using repository: %s', repository); 80 | } 81 | this.installTL ??= await acquire({ repository, version: this.version }); 82 | await this.installTL.run({ 83 | profile: this.options.profile, 84 | repository, 85 | }); 86 | } 87 | 88 | private async pickRepository(): Promise { 89 | if (this.try === 1 && this.options.repository !== undefined) { 90 | return new URL(this.options.repository); 91 | } 92 | if (this.version < ReleaseData.use().latest.version) { 93 | switch (this.try) { 94 | case 1: 95 | return tlnet.historic(this.version); 96 | case 2: 97 | return new URL( 98 | `https://mirrors.tuna.tsinghua.edu.cn/tex-historic-archive/systems/texlive/${this.version}/tlnet-final/`, 99 | ); 100 | } 101 | } else { 102 | switch (this.try) { 103 | case 1: 104 | return await tlnet.ctan({ master: false }); 105 | case 2: 106 | return new URL(data.ctan.path, data.ctan.default); 107 | } 108 | } 109 | throw new Error('Failed to find a suitable repository'); 110 | } 111 | 112 | get version(): Version { 113 | return this.options.profile.version; 114 | } 115 | } 116 | 117 | /* eslint no-await-in-loop: off */ 118 | -------------------------------------------------------------------------------- /packages/action/src/runs/post.ts: -------------------------------------------------------------------------------- 1 | import { save as saveCache } from '#action/cache'; 2 | 3 | export async function post(): Promise { 4 | await saveCache(); 5 | } 6 | -------------------------------------------------------------------------------- /packages/action/vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { mergeConfig } from 'vitest/config'; 2 | 3 | import sharedConfig from '@setup-texlive-action/config/vitest'; 4 | import fixtures from '@setup-texlive-action/fixtures'; 5 | 6 | export default mergeConfig(sharedConfig, { 7 | plugins: [fixtures()], 8 | test: { 9 | setupFiles: ['__tests__/setup.ts'], 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/config/README.md: -------------------------------------------------------------------------------- 1 | # @setup-texlive-action/config 2 | 3 | > Internal configuration files 4 | -------------------------------------------------------------------------------- /packages/config/cliff.toml: -------------------------------------------------------------------------------- 1 | [changelog] 2 | header = "" 3 | body = """ 4 | {{- version | default(value="Unreleased") }} 5 | 6 | {% if commit_id -%} 7 | ### Full Commit SHA 8 | 9 | ```sha1 10 | {{ commit_id }} 11 | ``` 12 | 13 | {% endif -%} 14 | {% for commit in commits | filter(attribute="breaking", value=true) -%} 15 | {% if loop.first -%} 16 | ### Breaking Changes 17 | 18 | {% endif -%} 19 | - {{ commit.breaking_description }} 20 | {% if loop.last %} 21 | {% endif -%} 22 | {% endfor -%} 23 | {% for group, commits in commits | group_by(attribute="group") -%} 24 | ### {{ group | striptags }} 25 | 26 | {% for commit in commits -%} 27 | - {{ commit.id }} \ 28 | {% if commit.scope != "" -%} 29 | **({{ commit.scope }})** \ 30 | {%- endif -%} 31 | {{ commit.message }} 32 | {% endfor -%} 33 | {% if not loop.last %} 34 | {% endif -%} 35 | {% endfor %} 36 | """ 37 | footer = "" 38 | trim = true 39 | 40 | [git] 41 | conventional_commits = true 42 | commit_preprocessors = [ 43 | { pattern = "\\s*\\[skip ci\\]", replace = "" }, 44 | ] 45 | commit_parsers = [ 46 | { message = "^feat", group = "<1>Features", default_scope = "" }, 47 | { message = "^fix\\(unreleased\\)", skip = true }, 48 | { message = "^fix", group = "<2>Bug Fixes", default_scope = "" }, 49 | { message = "^perf", group = "<3>Performance Improvements", default_scope = "" }, 50 | { message = "^build\\(deps\\)", group = "<4>Dependency Updates", scope = "" }, 51 | { message = ".*", skip = true }, 52 | ] 53 | protect_breaking_commits = true 54 | tag_pattern = "v*.*.*" 55 | -------------------------------------------------------------------------------- /packages/config/dprint/dprint.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "lineWidth": 80, 3 | "incremental": false, 4 | "json": { 5 | "jsonTrailingCommaFiles": [ 6 | "tsconfig.json", 7 | "tsconfig.base.json", 8 | ], 9 | }, 10 | "typescript": { 11 | "quoteStyle": "preferSingle", 12 | "quoteProps": "asNeeded", 13 | "useBraces": "always", 14 | "singleBodyPosition": "nextLine", 15 | "computed.preferSingleLine": true, 16 | "exportDeclaration.preferSingleLine": true, 17 | "importDeclaration.preferSingleLine": true, 18 | "parameters.preferSingleLine": true, 19 | "variableStatement.preferSingleLine": true, 20 | "arrowFunction.useParentheses": "force", 21 | "binaryExpression.linePerExpression": true, 22 | "memberExpression.linePerExpression": true, 23 | "commentLine.forceSpaceAfterSlashes": true, 24 | "typeAssertion.spaceBeforeExpression": false, 25 | "module.sortImportDeclarations": "caseSensitive", 26 | "module.sortExportDeclarations": "caseSensitive", 27 | "exportDeclaration.sortNamedExports": "caseSensitive", 28 | "importDeclaration.sortNamedImports": "caseSensitive", 29 | }, 30 | "yaml": { 31 | "quotes": "preferSingle", 32 | "formatComments": true, 33 | }, 34 | "excludes": [ 35 | "/packages/fixtures/data", 36 | "coverage", 37 | "dist", 38 | "lib", 39 | "package-lock.json", 40 | ], 41 | "plugins": [ 42 | "https://plugins.dprint.dev/json-0.20.0.wasm", 43 | "https://plugins.dprint.dev/markdown-0.18.0.wasm", 44 | "https://plugins.dprint.dev/toml-0.7.0.wasm", 45 | "https://plugins.dprint.dev/typescript-0.94.0.wasm", 46 | "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.0.wasm", 47 | ], 48 | } 49 | -------------------------------------------------------------------------------- /packages/config/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | /** @satisfies {import('esbuild').CommonOptions} */ 2 | export const transformConfig = { 3 | /** @type {'node20'} */ 4 | target: 'node20', 5 | format: 'esm', 6 | platform: 'node', 7 | legalComments: 'inline', 8 | keepNames: true, 9 | }; 10 | 11 | const requireShim = String.raw` 12 | await import('node:module').then(({ createRequire }) => { 13 | Object.defineProperty(globalThis, 'require', { 14 | value: createRequire(import.meta.url), 15 | configurable: false, 16 | enumerable: false, 17 | writable: false, 18 | }); 19 | }); 20 | `; 21 | 22 | /** @type {import('esbuild').BuildOptions} */ 23 | export default { 24 | ...transformConfig, 25 | bundle: true, 26 | mainFields: ['module', 'jsnext:main', 'jsnext', 'main'], 27 | conditions: ['bundler', 'import', 'module'], 28 | banner: { 29 | // See: https://github.com/evanw/esbuild/issues/1921#issuecomment-1152991694 30 | js: requireShim.trim(), 31 | }, 32 | resolveExtensions: ['.ts', '.mjs', '.js', '.json'], 33 | logLevel: 'info', 34 | }; 35 | -------------------------------------------------------------------------------- /packages/config/nunjucks/NOTICE.md.njk: -------------------------------------------------------------------------------- 1 | # Third-Party Software Licenses and Copyright Notices 2 | 3 | This software incorporates the following third-party software components: 4 | 5 | {% for name, _ in modules %} 6 | - [`{{ name }}`](#{{ name | slugify }}) 7 | {% endfor %} 8 | 9 | ------ 10 | {% for name, versions in modules %} 11 | 12 | ## `{{ name }}` 13 | 14 | URL: 15 | {% for m in versions %} 16 | 17 | ### v{{ m.version }} 18 | 19 | {% if m.author %} 20 | Copyright (c) {{ m.author | escape }} 21 | 22 | {% endif -%} 23 | 24 | License: `{{ m.license }}` 25 | 26 | ``` 27 | {% if m.licenseText -%} 28 | {{ m.licenseText.trimEnd() }} 29 | {% else -%} 30 | {% set license = spdx[m.license] %} 31 | {{ license.name }} ({{ license.url }}) 32 | {% endif %} 33 | ``` 34 | {% endfor %} 35 | {% endfor %} 36 | -------------------------------------------------------------------------------- /packages/config/nunjucks/markdown.mjs: -------------------------------------------------------------------------------- 1 | import GitHubSlugger from 'github-slugger'; 2 | import nunjucks from 'nunjucks'; 3 | 4 | const env = nunjucks.configure({ 5 | autoescape: false, 6 | throwOnUndefined: true, 7 | trimBlocks: true, 8 | lstripBlocks: true, 9 | noCache: true, 10 | }); 11 | const indent = env.getFilter('indent'); 12 | const slugger = new GitHubSlugger(); 13 | 14 | export default env 15 | .addFilter('indent', (str, width = 4, first = false, blank = false) => { 16 | const indented = indent(str, width, first); 17 | return blank ? indented : indented.replaceAll(/^\s+$/gmv, ''); 18 | }) 19 | .addFilter('escape', (str) => str.replaceAll(/[<>]/gv, '\\$&')) 20 | .addFilter('slugify', (str) => slugger.slug(str)); 21 | -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setup-texlive-action/config", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": {}, 7 | "exports": { 8 | "./esbuild": "./esbuild.config.mjs", 9 | "./eslint": "./eslint.config.mjs", 10 | "./nunjucks/*": "./nunjucks/*.mjs", 11 | "./tsconfig.base.json": "./tsconfig.base.json", 12 | "./tsdoc.json": "./tsdoc.json", 13 | "./vitest": "./vitest/vitest.config.mjs", 14 | "./vitest/*": "./vitest/*" 15 | }, 16 | "devDependencies": { 17 | "@eslint/js": "^9.24.0", 18 | "@tsconfig/node20": "^20.1.5", 19 | "@tsconfig/strictest": "^2.0.5", 20 | "@types/nunjucks": "^3.2.6", 21 | "@types/semver": "^7.7.0", 22 | "@vitest/coverage-v8": "^3.1.1", 23 | "@vitest/eslint-plugin": "^1.1.42", 24 | "esbuild-loader": "^4.3.0", 25 | "eslint-plugin-import-x": "^4.10.5", 26 | "eslint-plugin-jsdoc": "^50.6.9", 27 | "eslint-plugin-n": "^17.17.0", 28 | "eslint-plugin-regexp": "^2.7.0", 29 | "eslint-plugin-tsdoc": "^0.4.0", 30 | "eslint-plugin-unicorn": "^58.0.0", 31 | "github-slugger": "^2.0.0", 32 | "nunjucks": "^3.2.4", 33 | "semver": "^7.7.2", 34 | "spdx-license-list": "^6.10.0", 35 | "typescript-eslint": "^8.30.1", 36 | "vite-tsconfig-paths": "^5.1.4", 37 | "webpack-license-plugin": "^4.5.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/config/rspack/plugin-licenses.mjs: -------------------------------------------------------------------------------- 1 | import * as assert from 'node:assert/strict'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | import { compare as semverCompare } from 'semver'; 5 | import spdx from 'spdx-license-list'; 6 | import LicensePlugin from 'webpack-license-plugin'; 7 | /** 8 | * @typedef 9 | * {Required[0]>>} 10 | * IPluginOptions 11 | * @typedef 12 | * {Parameters[0][number]} 13 | * IPackageLicenseMeta 14 | */ 15 | 16 | import nunjucks from '../nunjucks/markdown.mjs'; 17 | 18 | const allowList = new Set([ 19 | '(Apache-2.0 AND BSD-3-Clause)', 20 | '0BSD', 21 | 'Apache-2.0', 22 | 'BSD-3-Clause', 23 | 'ISC', 24 | 'MIT', 25 | ]); 26 | const output = 'NOTICE.md'; 27 | const templatePath = fileURLToPath( 28 | import.meta.resolve(`../nunjucks/${output}.njk`), 29 | ); 30 | 31 | /** @type {import('webpack').WebpackPluginFunction} */ 32 | export default function pluginLicenses(compiler) { 33 | const plugin = new LicensePlugin({ 34 | unacceptableLicenseTest: (id) => !allowList.has(id), 35 | excludedPackageTest: (name) => name.startsWith('@setup-texlive-action/'), 36 | additionalFiles: { [output]: render }, 37 | includeNoticeText: true, 38 | }); 39 | plugin.apply(compiler); 40 | 41 | const name = 'DeleteAssets'; 42 | compiler.hooks.compilation.tap(name, (compilation) => { 43 | compilation.hooks.afterProcessAssets.tap(name, (assets) => { 44 | for (const asset of Object.keys(assets)) { 45 | if (asset !== output) { 46 | Reflect.deleteProperty(assets, asset); 47 | } 48 | } 49 | }); 50 | }); 51 | } 52 | 53 | /** 54 | * @param {IPackageLicenseMeta[]} packages 55 | * @returns {string} 56 | */ 57 | function render(packages) { 58 | assert.notEqual(packages.length, 0, 'No packages found'); 59 | console.table(packages, ['name', 'version', 'license']); 60 | for (const { name, version, noticeText } of packages) { 61 | assert.equal(noticeText, undefined, `${name}@${version} has notice text`); 62 | } 63 | /** @type {Partial<{ [name: string]: IPackageLicenseMeta[] }>} */ 64 | // @ts-expect-error 65 | const modules = Object.groupBy(packages, ({ name }) => name); 66 | for (const [key, value] of Object.entries(modules)) { 67 | modules[key] = value?.sort((lhs, rhs) => { 68 | return -semverCompare(lhs.version, rhs.version); 69 | }); 70 | } 71 | return nunjucks.render(templatePath, { modules, spdx }); 72 | } 73 | -------------------------------------------------------------------------------- /packages/config/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@tsconfig/node20/tsconfig.json", 4 | "@tsconfig/strictest/tsconfig.json", 5 | ], 6 | "compilerOptions": { 7 | "noUnusedParameters": false, 8 | "resolveJsonModule": true, 9 | "importHelpers": true, 10 | "newLine": "lf", 11 | "noEmitOnError": true, 12 | "removeComments": true, 13 | "stripInternal": true, 14 | "allowSyntheticDefaultImports": false, 15 | "esModuleInterop": false, 16 | "verbatimModuleSyntax": true, 17 | "experimentalDecorators": true, 18 | "module": "node18", 19 | "target": "esnext", 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /packages/config/tsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", 3 | "tagDefinitions": [ 4 | { 5 | "tagName": "@yields", 6 | "syntaxKind": "block" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /packages/config/vitest/raw-serializer.ts: -------------------------------------------------------------------------------- 1 | export function serialize(val: string): string { 2 | return val; 3 | } 4 | 5 | export function test(val: unknown): val is string { 6 | return typeof val === 'string'; 7 | } 8 | -------------------------------------------------------------------------------- /packages/config/vitest/suppress-output.mjs: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, vi } from 'vitest'; 2 | 3 | const spyStdout = vi.spyOn(globalThis.process.stdout, 'write'); 4 | 5 | beforeAll(() => { 6 | spyStdout.mockReturnValue(true); 7 | }); 8 | 9 | afterAll(() => { 10 | spyStdout.mockRestore(); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/config/vitest/vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | 3 | import tsConfigPaths from 'vite-tsconfig-paths'; 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | import esbuildConfig, { transformConfig } from '../esbuild.config.mjs'; 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | tsConfigPaths(), 11 | ], 12 | test: { 13 | setupFiles: [ 14 | fileURLToPath(import.meta.resolve('./suppress-output.mjs')), 15 | ], 16 | clearMocks: true, 17 | unstubEnvs: true, 18 | chaiConfig: { 19 | includeStack: true, 20 | truncateThreshold: 1000, 21 | }, 22 | sequence: { 23 | hooks: 'stack', 24 | setupFiles: 'list', 25 | }, 26 | resolveSnapshotPath: (testPath, snapExtension) => { 27 | return testPath.replace('/__tests__/', '/__snapshots__/') + snapExtension; 28 | }, 29 | coverage: { 30 | provider: 'v8', 31 | reporter: ['text', 'json'], 32 | reportOnFailure: true, 33 | }, 34 | watch: false, 35 | }, 36 | resolve: { 37 | conditions: esbuildConfig.conditions ?? [], 38 | mainFields: esbuildConfig.mainFields ?? [], 39 | }, 40 | esbuild: transformConfig, 41 | }); 42 | -------------------------------------------------------------------------------- /packages/data/.gitattributes: -------------------------------------------------------------------------------- 1 | /data/package-names.json linguist-generated 2 | -------------------------------------------------------------------------------- /packages/data/README.md: -------------------------------------------------------------------------------- 1 | # @setup-texlive-action/data 2 | 3 | > Static data files on something around TeX Live 4 | -------------------------------------------------------------------------------- /packages/data/Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | method: none 3 | tasks: 4 | ajv: 5 | internal: true 6 | requires: 7 | vars: [schema, data] 8 | cmd: >- 9 | ajv 10 | --spec=draft2020 11 | --strict 12 | --all-errors 13 | -c ajv-formats 14 | -r schemas/target.schema.json 15 | -s {{ .schema }} 16 | -d {{ .data }} 17 | default: 18 | sources: ['data/*.json'] 19 | cmd: 20 | for: sources 21 | task: ajv 22 | vars: 23 | data: '{{ .ITEM }}' 24 | schema: >- 25 | schemas/{{ .ITEM | osBase | trimSuffix ".json" }}.schema.json 26 | -------------------------------------------------------------------------------- /packages/data/data/texlive-versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../schemas/texlive-versions.schema.json", 3 | "previous": { 4 | "version": "2024", 5 | "releaseDate": "2024-03-13T17:37:00Z" 6 | }, 7 | "current": { 8 | "version": "2025", 9 | "releaseDate": "2025-03-15T00:00:00Z" 10 | }, 11 | "next": { 12 | "version": "2026", 13 | "releaseDate": "2026-03-07" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/data/data/tlnet.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../schemas/tlnet.schema.json", 3 | "ctan": { 4 | "master": "https://ctan.org/", 5 | "mirrors": "https://mirrors.ctan.org/", 6 | "default": "https://mirror.math.princeton.edu/pub/CTAN/", 7 | "path": "systems/texlive/tlnet/", 8 | "versionFile": "TEXLIVE_{version}" 9 | }, 10 | "tlcontrib": { 11 | "mirrors": "https://mirrors.ctan.org/", 12 | "path": "systems/texlive/tlcontrib/" 13 | }, 14 | "historic": { 15 | "master": "ftp://tug.org/historic/", 16 | "default": "https://ftp.math.utah.edu/pub/texlive/historic/", 17 | "path": { 18 | "systems/texlive/{version}/tlnet/": { 19 | "versions": "<2010" 20 | }, 21 | "systems/texlive/{version}/tlnet-final/": { 22 | "versions": ">=2010" 23 | } 24 | } 25 | }, 26 | "tlpretest": { 27 | "default": "https://ftp.math.utah.edu/pub/texlive/", 28 | "path": "tlpretest/", 29 | "versionFile": "TEXLIVE_{version}_pretest" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/data/data/tlpkg-patches.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../schemas/tlpkg-patches.schema.json", 3 | "patches": [ 4 | { 5 | "description": "Fixes a syntax error", 6 | "versions": ">=2009 <2011", 7 | "file": "tlpkg/TeXLive/TLWinGoo.pm", 8 | "changes": [ 9 | { 10 | "from": "foreach \\$p qw\\((.*)\\)", 11 | "to": "foreach $$p (qw($1))" 12 | } 13 | ] 14 | }, 15 | { 16 | "description": "Defines Code Page 65001 as an alias for UTF-8", 17 | "platform": "win32", 18 | "versions": "2015", 19 | "file": "tlpkg/tlperl/lib/Encode/Alias.pm", 20 | "changes": [ 21 | { 22 | "from": "# utf8 is blessed :\\)", 23 | "to": "define_alias( qr/\\bcp65001$$/i => '\"utf-8-strict\"' );" 24 | } 25 | ] 26 | }, 27 | { 28 | "description": "Makes it possible to use `\\` as a directory separator", 29 | "platform": "win32", 30 | "versions": "<2019", 31 | "file": "tlpkg/TeXLive/TLUtils.pm", 32 | "changes": [ 33 | { 34 | "from": "split \\(/\\\\//, \\$tree\\)", 35 | "to": "split (/[\\/\\\\]/, $$tree)" 36 | } 37 | ] 38 | }, 39 | { 40 | "description": "Adds support for macOS 11 or later", 41 | "platform": "darwin", 42 | "versions": ">=2017 <2020", 43 | "file": "tlpkg/TeXLive/TLUtils.pm", 44 | "changes": [ 45 | { 46 | "from": "\\$os_major != 10", 47 | "to": "$$os_major < 10" 48 | }, 49 | { 50 | "from": "\\$os_minor >= \\$mactex_darwin", 51 | "to": "$$os_major > 10 || $&" 52 | } 53 | ] 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /packages/data/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setup-texlive-action/data", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsx scripts/generate-namemap.ts -o data/package-names.json", 8 | "test": "task" 9 | }, 10 | "exports": { 11 | ".": "./src/index.ts", 12 | "./*": "./data/*" 13 | }, 14 | "sideEffects": false, 15 | "dependencies": { 16 | "minimatch": "^10.0.1" 17 | }, 18 | "devDependencies": { 19 | "ajv-cli": "^5.0.0", 20 | "ajv-formats": "^3.0.1", 21 | "citty": "^0.1.6", 22 | "consola": "^3.4.2", 23 | "texlive-json-schemas": "^0.2.0", 24 | "url-template": "^3.1.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/data/schemas/package-names.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "package-names.schema.json", 3 | "$schema": "https://json-schema.org/draft/2020-12/schema", 4 | "type": "object", 5 | "properties": { 6 | "toTL": { 7 | "description": "Package-name mapping from CTAN to TeX Live", 8 | "type": "object", 9 | "patternProperties": { 10 | "^\\S*$": { 11 | "oneOf": [ 12 | { "type": "string" }, 13 | { "type": "array", "items": { "type": "string" } } 14 | ] 15 | } 16 | }, 17 | "additionalProperties": false 18 | }, 19 | "generated": { 20 | "description": "Approximate date and time of generation", 21 | "type": "string", 22 | "format": "date-time" 23 | } 24 | }, 25 | "required": ["toTL"], 26 | "additionalProperties": false 27 | } 28 | -------------------------------------------------------------------------------- /packages/data/schemas/target.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "target.schema.json", 3 | "$schema": "https://json-schema.org/draft/2020-12/schema", 4 | "description": "Predicate for filtering entries", 5 | "type": "object", 6 | "properties": { 7 | "platform": { 8 | "description": "Minimatch pattern for Node.js platform string", 9 | "type": "string", 10 | "examples": ["darwin", "linux", "!win32"] 11 | }, 12 | "arch": { 13 | "description": "Minimatch pattern for Node.js CPU architecture string", 14 | "type": "string", 15 | "examples": ["arm", "{arm64,x64}"] 16 | }, 17 | "versions": { 18 | "description": "Semver range", 19 | "type": "string", 20 | "examples": ["2008", ">2010 <=2020"] 21 | } 22 | }, 23 | "required": [] 24 | } 25 | -------------------------------------------------------------------------------- /packages/data/schemas/texlive-versions.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "texlive-versions.schema.json", 3 | "$schema": "https://json-schema.org/draft/2020-12/schema", 4 | "description": "TeX Live versions", 5 | "type": "object", 6 | "properties": { 7 | "previous": { 8 | "type": "object", 9 | "properties": { 10 | "version": { 11 | "$ref": "#/$defs/version" 12 | }, 13 | "releaseDate": { 14 | "type": "string", 15 | "format": "date-time" 16 | } 17 | }, 18 | "required": ["version", "releaseDate"], 19 | "patternProperties": { 20 | "^\\$.*$": {} 21 | }, 22 | "additionalProperties": false 23 | }, 24 | "current": { 25 | "$ref": "#/properties/previous" 26 | }, 27 | "next": { 28 | "type": "object", 29 | "properties": { 30 | "version": { 31 | "$ref": "#/$defs/version" 32 | }, 33 | "releaseDate": { 34 | "type": "string", 35 | "format": "date" 36 | } 37 | }, 38 | "required": ["version", "releaseDate"], 39 | "patternProperties": { 40 | "^\\$.*$": {} 41 | }, 42 | "additionalProperties": false 43 | } 44 | }, 45 | "required": ["previous", "current", "next"], 46 | "patternProperties": { 47 | "^\\$.*$": {} 48 | }, 49 | "additionalProperties": false, 50 | "$defs": { 51 | "version": { 52 | "type": "string", 53 | "pattern": "^20\\d\\d$" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/data/schemas/tlnet.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "tlnet.schema.json", 3 | "$schema": "https://json-schema.org/draft/2020-12/schema", 4 | "type": "object", 5 | "properties": { 6 | "ctan": { 7 | "$ref": "#/$defs/tlnet" 8 | }, 9 | "tlcontrib": { 10 | "$ref": "#/$defs/tlnet" 11 | }, 12 | "historic": { 13 | "$ref": "#/$defs/tlnet" 14 | }, 15 | "tlpretest": { 16 | "$ref": "#/$defs/tlnet" 17 | } 18 | }, 19 | "required": ["ctan", "tlcontrib", "historic", "tlpretest"], 20 | "patternProperties": { 21 | "^\\$.*$": {} 22 | }, 23 | "additionalProperties": false, 24 | "$defs": { 25 | "tlnet": { 26 | "type": "object", 27 | "properties": { 28 | "master": { 29 | "description": "Master repository URL", 30 | "type": "string", 31 | "format": "uri" 32 | }, 33 | "mirrors": { 34 | "description": "Mirror multiplexor URL", 35 | "type": "string", 36 | "format": "uri" 37 | }, 38 | "default": { 39 | "description": "Mirror URL used by default", 40 | "type": "string", 41 | "format": "uri" 42 | }, 43 | "path": { 44 | "description": "tlnet path", 45 | "oneOf": [ 46 | { 47 | "type": "string", 48 | "format": "uri-template" 49 | }, 50 | { 51 | "type": "object", 52 | "propertyNames": { 53 | "format": "uri-template" 54 | }, 55 | "additionalProperties": { 56 | "type": "object", 57 | "allOf": [ 58 | { 59 | "$ref": "target.schema.json#" 60 | } 61 | ], 62 | "minProperties": 1, 63 | "unevaluatedProperties": false 64 | } 65 | } 66 | ] 67 | }, 68 | "versionFile": { 69 | "description": "Version file path", 70 | "type": "string", 71 | "format": "uri-template" 72 | } 73 | }, 74 | "required": [], 75 | "additionalProperties": false 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/data/schemas/tlpkg-patches.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "tlpkg-patches.schema.json", 3 | "$schema": "https://json-schema.org/draft/2020-12/schema", 4 | "description": "Patches for `tlpkg`", 5 | "type": "object", 6 | "properties": { 7 | "patches": { 8 | "type": "array", 9 | "items": { 10 | "$ref": "#/$defs/patch" 11 | } 12 | } 13 | }, 14 | "required": ["patches"], 15 | "patternProperties": { 16 | "^\\$.*$": {} 17 | }, 18 | "additionalProperties": false, 19 | "$defs": { 20 | "patch": { 21 | "type": "object", 22 | "allOf": [ 23 | { 24 | "type": "object", 25 | "properties": { 26 | "description": { 27 | "type": "string" 28 | }, 29 | "file": { 30 | "type": "string", 31 | "format": "uri-reference" 32 | }, 33 | "changes": { 34 | "type": "array", 35 | "items": { 36 | "$ref": "#/$defs/change" 37 | }, 38 | "minItems": 1, 39 | "uniqueItems": true 40 | } 41 | }, 42 | "required": ["file", "changes"] 43 | }, 44 | { 45 | "$ref": "target.schema.json#" 46 | } 47 | ], 48 | "unevaluatedProperties": false 49 | }, 50 | "change": { 51 | "type": "object", 52 | "properties": { 53 | "from": { 54 | "type": "string", 55 | "format": "regex" 56 | }, 57 | "to": { 58 | "type": "string" 59 | } 60 | }, 61 | "required": ["from", "to"], 62 | "additionalProperties": false 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/data/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'node:os'; 2 | 3 | import { type MinimatchOptions, minimatch } from 'minimatch'; 4 | import type { Range, RangeOptions } from 'semver'; 5 | import coerce from 'semver/functions/coerce.js'; 6 | import inRange from 'semver/functions/satisfies.js'; 7 | import type { DeepReadonly } from 'ts-essentials'; 8 | 9 | /** 10 | * @see `../schemas/target.schema.json` 11 | */ 12 | export interface Target { 13 | platform?: string; 14 | arch?: string; 15 | versions?: string | Range; 16 | } 17 | 18 | export interface Options { 19 | /** @defaultValue `os.platform()` */ 20 | platform?: NodeJS.Platform; 21 | /** @defaultValue `os.arch()` */ 22 | arch?: NodeJS.Architecture; 23 | version?: string; 24 | } 25 | 26 | const semverOptions = { 27 | includePrerelease: true, 28 | } as const satisfies RangeOptions; 29 | 30 | const minimatchOptions = { 31 | platform: 'linux', 32 | } as const satisfies MinimatchOptions; 33 | 34 | /** 35 | * Check if current platform is in scope. 36 | */ 37 | export function satisfies( 38 | target: DeepReadonly, 39 | options?: DeepReadonly, 40 | ): boolean { 41 | let result = true; 42 | if ('platform' in target) { 43 | result &&= minimatch( 44 | options?.platform ?? os.platform(), 45 | target.platform, 46 | minimatchOptions, 47 | ); 48 | } 49 | if ('arch' in target) { 50 | result &&= minimatch( 51 | options?.arch ?? os.arch(), 52 | target.arch, 53 | minimatchOptions, 54 | ); 55 | } 56 | if ('versions' in target && options?.version !== undefined) { 57 | const v = coerce(options.version, semverOptions); 58 | if (v === null) { 59 | const error = new TypeError('Invalid version string'); 60 | error['value'] = options.version; 61 | throw error; 62 | } 63 | result &&= inRange(v, target.versions, semverOptions); 64 | } 65 | return result; 66 | } 67 | 68 | /** 69 | * Pick an entry from the pattern object that matches the current platform. 70 | * @param patterns - Pattern object. 71 | * @param options - Options for the current platform. 72 | */ 73 | export function match>( 74 | patterns: T, 75 | options?: DeepReadonly, 76 | ): [keyof T, T[keyof T]] { 77 | for (const entry of Object.entries(patterns)) { 78 | if (satisfies(entry[1], options)) { 79 | return entry as ReturnType>; 80 | } 81 | } 82 | const error = new Error('None of the patterns matched'); 83 | const { platform = os.platform(), arch = os.arch(), version } = options ?? {}; 84 | Object.assign(error, { patterns, platform, arch, version }); 85 | throw error; 86 | } 87 | -------------------------------------------------------------------------------- /packages/e2e/README.md: -------------------------------------------------------------------------------- 1 | # @setup-texlive-action/e2e 2 | 3 | > E2E test files and helper scripts 4 | 5 | Local testing is done inside a container using [`act`]. 6 | The `act` binary is automatically installed during `npm ci` by [`@kie/act-js`]. 7 | 8 | ## Prerequisites 9 | 10 | - `docker` 11 | 12 | ### Container Images 13 | 14 | 15 | - [node]:20.0 16 | - [ubuntu/squid]:latest 17 | 18 | 19 | ## Testing 20 | 21 | ```sh 22 | npm run e2e [target] 23 | ``` 24 | 25 | ```sh 26 | npm run e2e -- --list # Lists all test targets 27 | ``` 28 | 29 | [`@kie/act-js`]: https://www.npmjs.com/package/@kie/act-js 30 | [`act`]: https://nektosact.com 31 | [node]: https://hub.docker.com/_/node 32 | [ubuntu/squid]: https://hub.docker.com/r/ubuntu/squid 33 | -------------------------------------------------------------------------------- /packages/e2e/Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | vars: 3 | basedir: '{{ .TASKFILE_DIR | relPath .npm_config_local_prefix }}' 4 | tasks: 5 | act: 6 | internal: true 7 | requires: 8 | vars: [workflows] 9 | <<: &act 10 | desc: 'Run: {{ .workflows }}' 11 | dir: '{{ .npm_config_local_prefix }}' 12 | cmd: >- 13 | act-js 14 | --container-architecture linux/{{ default ARCH .architecture }} 15 | --workflows {{ .workflows }} 16 | {{ .CLI_ARGS }} 17 | clear-cache: 18 | cmd: rm -rf "${npm_config_local_prefix}/node_modules/.act" 19 | default: 20 | desc: Just checks if the action works with default settings. 21 | cmd: 22 | task: act 23 | vars: 24 | CLI_ARGS: >- 25 | --job save-cache 26 | --no-cache-server 27 | workflows: .github/workflows/e2e.yml 28 | historic: &run-global-workflow 29 | <<: *act 30 | vars: 31 | architecture: amd64 32 | workflows: .github/workflows/e2e-{{ .TASK }}.yml 33 | proxy: *run-global-workflow 34 | test: 35 | <<: *act 36 | deps: [clear-cache] 37 | vars: 38 | workflows: .github/workflows/e2e.yml 39 | fallback-to-historic-master: &run-local-workflow 40 | <<: *act 41 | deps: [clear-cache] 42 | vars: 43 | workflows: '{{ .basedir }}/workflows/{{ .TASK }}.yml' 44 | move-to-historic: *run-local-workflow 45 | tlpretest: *run-local-workflow 46 | 47 | cache-on-failure: 48 | <<: &cache-on-failure 49 | vars: 50 | workflow: '{{ .TASK | splitList ":" | first }}' 51 | case: '{{ .TASK | trimPrefix .workflow | trimPrefix ":" }}' 52 | desc: >- 53 | Run: {{ .basedir }}/workflows/{{ .workflow }}.yml 54 | {{ if .case }}({{ .case }}){{ end }} 55 | cmd: >- 56 | vitest {{- if .case }} --testNamePattern '^{{ .case }}$'{{ end }} 57 | cache-on-failure:default: *cache-on-failure 58 | cache-on-failure:with-0: *cache-on-failure 59 | cache-on-failure:with-1: *cache-on-failure 60 | cache-on-failure:with-true: *cache-on-failure 61 | cache-on-failure:with-: *cache-on-failure 62 | -------------------------------------------------------------------------------- /packages/e2e/__mocks__/@kie/act-js.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | import { mkdtemp, rm } from 'node:fs/promises'; 4 | import { tmpdir } from 'node:os'; 5 | import * as path from 'node:path'; 6 | 7 | import { Act } from '@kie/act-js'; 8 | 9 | const originalRun = Reflect.get(Act.prototype, 'run') as typeof run; 10 | 11 | // Ensure that a new cache entry is created each time 12 | vi 13 | .spyOn(Act.prototype, 'run' as keyof Act) 14 | .mockImplementation(run as ReturnType); 15 | 16 | // Prevent `act-js` from creating `.actrc` file in home directory 17 | vi 18 | .spyOn(Act.prototype, 'setDefaultImage' as keyof Act) 19 | .mockImplementation(vi.fn()); 20 | 21 | async function run( 22 | this: Act, 23 | cmd: string[], 24 | ...args: unknown[] 25 | ): Promise { 26 | const tmp = await mkdtemp(path.join(tmpdir(), 'act-')); 27 | await using _ = { // eslint-disable-line @typescript-eslint/no-unused-vars 28 | async [Symbol.asyncDispose]() { 29 | await rm(tmp, { force: true, recursive: true }); 30 | }, 31 | }; 32 | console.error(`Using ${tmp} as cache server path`); 33 | cmd.push('--cache-server-path', tmp); 34 | return await originalRun.call(this, cmd, ...args); 35 | } 36 | 37 | export { Act }; 38 | -------------------------------------------------------------------------------- /packages/e2e/__snapshots__/cache-on-failure.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`default 1`] = `"Main Setup TeX Live"`; 4 | 5 | exports[`default 2`] = `"Main Check that a new installation has been made"`; 6 | 7 | exports[`default 3`] = `"Post Setup TeX Live"`; 8 | 9 | exports[`with- 1`] = `"Main Setup TeX Live"`; 10 | 11 | exports[`with- 2`] = `"Main Check that a new installation has been made"`; 12 | 13 | exports[`with-0 1`] = `"Main Setup TeX Live"`; 14 | 15 | exports[`with-0 2`] = `"Main Check that a new installation has been made"`; 16 | 17 | exports[`with-0 3`] = `"Post Setup TeX Live"`; 18 | 19 | exports[`with-1 1`] = `"Main Setup TeX Live"`; 20 | 21 | exports[`with-1 2`] = `"Main Check that a new installation has been made"`; 22 | 23 | exports[`with-true 1`] = `"Main Setup TeX Live"`; 24 | 25 | exports[`with-true 2`] = `"Main Check that a new installation has been made"`; 26 | -------------------------------------------------------------------------------- /packages/e2e/__tests__/cache-on-failure.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, expect, test, vi } from 'vitest'; 2 | 3 | import * as path from 'node:path'; 4 | import { env } from 'node:process'; 5 | 6 | import { Act, type RunOpts } from '@kie/act-js'; 7 | 8 | vi.mock('@kie/act-js'); 9 | 10 | // Use the repository root as cwd: 11 | const cwd = env['npm_config_local_prefix'] 12 | ?? path.resolve(import.meta.dirname, '../../../'); 13 | 14 | const job = 'test'; 15 | const workflow = path.resolve( 16 | import.meta.dirname, 17 | '../workflows/cache-on-failure.yml', 18 | ); 19 | // Send logs to stderr: 20 | const opts = { logFile: '/dev/stderr' } as const satisfies RunOpts; 21 | 22 | const key = 'SETUP_TEXLIVE_ACTION_NO_CACHE_ON_FAILURE'; 23 | const msg = /^Cache saved successfully$/gmv; 24 | const act = new Act(cwd, workflow); 25 | 26 | beforeEach(() => { 27 | act.clearEnv(); 28 | }); 29 | 30 | test('default', async () => { 31 | const steps = await act.runJob(job, opts); 32 | expect(steps[1]?.name).toMatchSnapshot(); 33 | expect(steps[1]?.status).toBe(0); 34 | expect(steps[2]?.name).toMatchSnapshot(); 35 | expect(steps[2]?.status).toBe(0); 36 | expect(steps[3]?.status).toBe(1); 37 | expect(steps[4]?.name).toMatchSnapshot(); 38 | expect(steps[4]?.status).toBe(0); 39 | expect(steps[4]?.output).toMatch(msg); 40 | }); 41 | 42 | test('with-0', async () => { 43 | act.setEnv(key, '0'); 44 | const steps = await act.runJob(job, opts); 45 | expect(steps[1]?.name).toMatchSnapshot(); 46 | expect(steps[1]?.status).toBe(0); 47 | expect(steps[2]?.name).toMatchSnapshot(); 48 | expect(steps[2]?.status).toBe(0); 49 | expect(steps[3]?.status).toBe(1); 50 | expect(steps[4]?.name).toMatchSnapshot(); 51 | expect(steps[4]?.status).toBe(0); 52 | expect(steps[4]?.output).toMatch(msg); 53 | }); 54 | 55 | test.for(['1', 'true', ''])('with-%s', async (value) => { 56 | act.setEnv(key, value); 57 | const steps = await act.runJob(job, opts); 58 | expect(steps[1]?.name).toMatchSnapshot(); 59 | expect(steps[1]?.status).toBe(0); 60 | expect(steps[2]?.name).toMatchSnapshot(); 61 | expect(steps[2]?.status).toBe(0); 62 | expect(steps[3]?.status).toBe(1); 63 | expect(steps[4]).toBeUndefined(); 64 | }); 65 | 66 | /* eslint n/no-unsupported-features/node-builtins: off */ 67 | -------------------------------------------------------------------------------- /packages/e2e/index.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Delete all caches created by this action. 3 | * 4 | * @param {object} args 5 | * @param {import('@actions/github').context} args.context 6 | * @param {import('@actions/core')} args.core 7 | * @param {ReturnType} args.github 8 | */ 9 | async function deleteCaches(args) { 10 | const { context, core, github: { paginate, rest } } = args; 11 | const { deleteActionsCacheByKey, getActionsCacheList } = rest.actions; 12 | const paginator = paginate.iterator(getActionsCacheList, { 13 | ...context.repo, 14 | per_page: 100, 15 | key: 'setup-texlive-action-', 16 | }); 17 | for await (const { data } of paginator) { 18 | for (const { key } of data) { 19 | if (key !== undefined) { 20 | core.info(`Deleting ${key}`); 21 | try { 22 | await deleteActionsCacheByKey({ ...context.repo, key }); 23 | } catch (error) { 24 | core.setFailed(`${error}`); 25 | } 26 | } 27 | } 28 | } 29 | } 30 | 31 | module.exports = { deleteCaches }; 32 | -------------------------------------------------------------------------------- /packages/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setup-texlive-action/e2e", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "e2e": "task" 7 | }, 8 | "type": "module", 9 | "main": "index.cjs", 10 | "devDependencies": { 11 | "@actions/github": "^6.0.0", 12 | "@kie/act-js": "^2.6.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/e2e/vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { mergeConfig } from 'vitest/config'; 2 | 3 | import defaultConfig from '@setup-texlive-action/config/vitest'; 4 | 5 | export default mergeConfig(defaultConfig, { 6 | test: { 7 | testTimeout: 5 * 60 * 1000, // 5min 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/e2e/workflows/cache-on-failure.yml: -------------------------------------------------------------------------------- 1 | on: workflow_dispatch 2 | jobs: 3 | test: 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - name: Setup TeX Live 8 | id: setup 9 | uses: ./ 10 | - name: Check that a new installation has been made 11 | run: exit ${{ fromJSON(steps.setup.outputs.cache-restored) || 1 && 0 }} 12 | - name: Always fails 13 | run: exit 1 14 | -------------------------------------------------------------------------------- /packages/e2e/workflows/fallback-to-historic-master.yml: -------------------------------------------------------------------------------- 1 | on: workflow_dispatch 2 | jobs: 3 | save-cache: 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - name: Setup TeX Live 8 | id: setup 9 | uses: ./ 10 | with: 11 | version: 2024 12 | - if: fromJSON(steps.setup.outputs.cache-restored) 13 | run: exit 1 14 | - run: >- 15 | tlmgr option repository ctan 16 | restore-cache: 17 | needs: save-cache 18 | runs-on: ubuntu-latest 19 | container: 20 | image: node:20.0 21 | options: --add-host=ftp.math.utah.edu:127.0.0.1 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Setup TeX Live 25 | id: setup 26 | uses: ./ 27 | with: 28 | version: 2024 29 | - if: ${{ !fromJSON(steps.setup.outputs.cache-hit) }} 30 | run: exit 1 31 | - run: >- 32 | tlmgr option repository | grep -F tug.org 33 | -------------------------------------------------------------------------------- /packages/e2e/workflows/move-to-historic.yml: -------------------------------------------------------------------------------- 1 | on: workflow_dispatch 2 | jobs: 3 | save-cache: 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - name: Setup TeX Live 8 | id: setup 9 | uses: ./ 10 | with: 11 | version: 2024 12 | - if: fromJSON(steps.setup.outputs.cache-restored) 13 | run: exit 1 14 | - run: | 15 | tlmgr option repository ctan 16 | tlmgr repository add https://mirrors.ctan.org/systems/texlive/tlcontrib/ tlcontrib 17 | restore-cache: 18 | needs: save-cache 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Setup TeX Live 23 | id: setup 24 | uses: ./ 25 | with: 26 | version: 2024 27 | - if: ${{ !fromJSON(steps.setup.outputs.cache-hit) }} 28 | run: exit 1 29 | -------------------------------------------------------------------------------- /packages/e2e/workflows/tlpretest.yml: -------------------------------------------------------------------------------- 1 | on: workflow_dispatch 2 | jobs: 3 | tlpretest: 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - name: Setup TeX Live 8 | uses: ./ 9 | with: 10 | repository: https://ftp.math.utah.edu/pub/tlpretest/ 11 | version: 2026 12 | - run: tlmgr version 13 | -------------------------------------------------------------------------------- /packages/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import config, { defineConfig } from '@setup-texlive-action/config/eslint'; 2 | 3 | const sourcefiles = '*/src/**/*.ts'; 4 | const mockfiles = '**/__mocks__/**/*.ts'; 5 | 6 | export default defineConfig( 7 | { 8 | files: [sourcefiles], 9 | ignores: [mockfiles], 10 | extends: [...config.common, ...config.sources], 11 | }, 12 | { 13 | files: [sourcefiles], 14 | ignores: [mockfiles, 'action/**', 'texlive/**', 'utils/**'], 15 | extends: config.docs, 16 | }, 17 | { 18 | files: ['*/__tests__/**/*.ts', mockfiles], 19 | ignores: [], 20 | extends: [...config.common, ...config.tests], 21 | }, 22 | ); 23 | -------------------------------------------------------------------------------- /packages/fixtures/.editorconfig: -------------------------------------------------------------------------------- 1 | [data/**/*] 2 | charset = unset 3 | end_of_line = unset 4 | indent_size = unset 5 | indent_style = unset 6 | insert_final_newline = unset 7 | trim_trailing_whitespace = unset 8 | -------------------------------------------------------------------------------- /packages/fixtures/.gitattributes: -------------------------------------------------------------------------------- 1 | /data/** -text -diff -merge linguist-generated 2 | *.log linguist-language=shellsession 3 | *.stderr linguist-language=shellsession 4 | *.stdout linguist-language=shellsession 5 | *.tlpdb linguist-language=gitattributes 6 | mirmon.state linguist-language=dircolors 7 | release-texlive.txt linguist-vendored !linguist-generated 8 | -------------------------------------------------------------------------------- /packages/fixtures/README.md: -------------------------------------------------------------------------------- 1 | # @setup-texlive-action/fixtures 2 | 3 | > Unit test fixtures and Vitest plugin 4 | 5 | ## Notice 6 | 7 | All files under the directory [`data`](./data) are public domain: 8 | 9 | - [`release-texlive.txt`](./data/release-texlive.txt) was 10 | taken from `install-tl-unx.tar.gz`; and 11 | - The other files are machine-generated data. 12 | -------------------------------------------------------------------------------- /packages/fixtures/data/TEXLIVE_2023.http: -------------------------------------------------------------------------------- 1 | HTTP/1.1 200 OK 2 | Date: Thu, 13 Jul 2023 05:01:16 GMT 3 | Server: Apache/2.4.25 (Debian) 4 | Upgrade: h2 5 | Connection: Upgrade 6 | Last-Modified: Sun, 19 Mar 2023 21:25:25 GMT 7 | ETag: "0-5f74772d9de86" 8 | Accept-Ranges: bytes 9 | Content-Type: text/plain 10 | 11 | -------------------------------------------------------------------------------- /packages/fixtures/data/ctan-api-pkg-shellesc.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "shellesc", 3 | "name": "shellesc", 4 | "aliases": [], 5 | "caption": "Unified shell escape interface for LaTeX", 6 | "authors": [ 7 | { 8 | "id": "latex", 9 | "active": true 10 | } 11 | ], 12 | "copyright": [ 13 | { 14 | "owner": "The LaTeX3 project", 15 | "year": "2015-2023" 16 | } 17 | ], 18 | "license": "lppl1.3c", 19 | "version": { 20 | "number": "1.0d", 21 | "date": "2023-04-15" 22 | }, 23 | "descriptions": [ 24 | { 25 | "language": null, 26 | "text": "

\n This package provides a cross-engine interface,\n \\ShellEscape, to running system commands,\n traditionally made available using \\write18.\n It also makes \\write18 access system commands\n (via Lua os.exec) on new LuaTeX, where \\write18\n does not have this feature by default.\n

\n

\n This package is part of the latex-tools\n bundle in the LaTeX required\n distribution.\n

" 27 | } 28 | ], 29 | "documentation": [ 30 | { 31 | "language": null, 32 | "details": "Package documentation", 33 | "href": "ctan:/macros/latex/required/tools/shellesc.pdf" 34 | } 35 | ], 36 | "ctan": { 37 | "path": "/macros/latex/required/tools", 38 | "file": true 39 | }, 40 | "install": "/macros/latex/required/latex-tools.tds.zip", 41 | "miktex": "latex-tools", 42 | "texlive": "tools", 43 | "topics": [ 44 | "sys-supp" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /packages/fixtures/data/ctan-api-pkg-texlive.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "texlive", 3 | "name": "texlive", 4 | "aliases": [], 5 | "caption": "A comprehensive distribution of TeX and friends", 6 | "authors": [ 7 | { 8 | "id": "texlive", 9 | "active": true 10 | } 11 | ], 12 | "copyright": [], 13 | "license": "other-free", 14 | "version": { 15 | "number": "2023", 16 | "date": "" 17 | }, 18 | "descriptions": [ 19 | { 20 | "language": null, 21 | "text": "

\n A comprehensive TeX system that you can install on your hard disk. It\n includes support for most Unix system architectures, including\n GNU/Linux and MacOS, and for Windows. The MacTeX\n distribution is an unchanged TeX Live plus some Mac-specific\n software, but is distributed in a separate archive.\n

\n

\n The TeX, PDF(e)TeX, XeTeX, LuaTeX, and other engines are provided in the\n distribution, with several different running formats each; a wide\n range of support programs and macro packages is also included.\n

\n

\n Beware: the download from CTAN is large (several GB); it comes\n in the form of an ISO image, and is available from CTAN mirrors\n through the `Sources' link below.\n

\n

\n Other ways to\n acquire TeX Live include network installation, tarballs, and\n mirroring.\n

" 22 | } 23 | ], 24 | "documentation": [ 25 | { 26 | "language": null, 27 | "details": "Readme", 28 | "href": "ctan:/systems/texlive/Images/README.md" 29 | } 30 | ], 31 | "home": "https://tug.org/texlive/", 32 | "support": "https://tug.org/texlive/lists.html", 33 | "bugs": "https://lists.tug.org/tex-live", 34 | "repository": "https://tug.org/texlive/svn/", 35 | "ctan": { 36 | "path": "/systems/texlive/Images", 37 | "file": true 38 | }, 39 | "topics": [ 40 | "distribution" 41 | ], 42 | "also": [ 43 | "texlive-source" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /packages/fixtures/data/install-tl-gpg.stderr: -------------------------------------------------------------------------------- 1 | 2 | ./install-tl-20241013/install-tl: signature verification error of /tmp/oKzVaHXD0r/LQbYvepj8v from https://ctan.org/tex-archive/systems/texlive/tlnet/tlpkg/texlive.tlpdb: cryptographic signature verification of 3 | /tmp/oKzVaHXD0r/aa5mk_BI9W 4 | against 5 | https://ctan.org/tex-archive/systems/texlive/tlnet/tlpkg/texlive.tlpdb.sha512.asc 6 | failed. Output was: 7 | gpg: Signature made Sun Oct 13 23:48:58 2024 UTC 8 | gpg: using RSA key D8F2F86057A857E42A88106A4CE1877E19438C70 9 | gpg: BAD signature from "TeX Live Distribution " [ultimate] 10 | 11 | Please try from a different mirror and/or wait a few minutes 12 | and try again; usually this is because of transient updates. 13 | If problems persist, feel free to report to texlive@tug.org. 14 | 15 | -------------------------------------------------------------------------------- /packages/fixtures/data/install-tl.stderr: -------------------------------------------------------------------------------- 1 | 2 | ============================================================================= 3 | ./install-tl: The TeX Live versions of the local installation 4 | and the repository being accessed are not compatible: 5 | local: 2022 6 | repository: 2023 7 | Perhaps you need to use a different CTAN mirror? 8 | (For more, see the output of install-tl --help, especially the 9 | -repository option. Online via https://tug.org/texlive/doc.) 10 | ============================================================================= 11 | -------------------------------------------------------------------------------- /packages/fixtures/data/mirrors.ctan.org.http: -------------------------------------------------------------------------------- 1 | HTTP/1.1 302 Found 2 | Date: Thu, 13 Jul 2023 05:18:26 GMT 3 | Server: Apache 4 | Location: https://ctan.math.washington.edu/tex-archive/ 5 | Content-Type: text/html; charset=iso-8859-1 6 | 7 | -------------------------------------------------------------------------------- /packages/fixtures/data/release-texlive.txt: -------------------------------------------------------------------------------- 1 | TeX Live (https://tug.org/texlive) version 2023 2 | 3 | This file is public domain. It is read by install-tl --version, 4 | tlmgr --version, and texconfig conf, and a final line appended with 5 | the precise version number by tl-update-images during a build. 6 | 7 | The following blank line helps avoid confusing output when 8 | used directly from svn, so don't delete it. 9 | 10 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlmgr-install.2008.stderr: -------------------------------------------------------------------------------- 1 | /usr/local/texlive/2008/bin/x86_64-linux/tlmgr: Cannot find package shellesc 2 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlmgr-install.2009.stderr: -------------------------------------------------------------------------------- 1 | package shellesc not present in package repository. 2 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlmgr-install.2014.stderr: -------------------------------------------------------------------------------- 1 | TeX Live 2014 is frozen forever and will no 2 | longer be updated. This happens in preparation for a new release. 3 | 4 | If you're interested in helping to pretest the new release (when 5 | pretests are available), please read http://tug.org/texlive/pretest.html. 6 | Otherwise, just wait, and the new release will be ready in due time. 7 | package shellesc not present in package repository. 8 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlmgr-install.2023.stderr: -------------------------------------------------------------------------------- 1 | tlmgr install: package shellesc not present in repository. 2 | tlmgr: action install returned an error; continuing. 3 | tlmgr: An error has occurred. See above messages. Exiting. 4 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlmgr-repository-add.stderr: -------------------------------------------------------------------------------- 1 | tlmgr: repository or its tag already defined, no action: https://mirror.ctan.org/systems/texlive/tlcontrib 2 | tlmgr: An error has occurred. See above messages. Exiting. 3 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlmgr-repository-list.stdout: -------------------------------------------------------------------------------- 1 | List of repositories (with tags if set): 2 | https://mirror.ctan.org/systems/texlive/tlcontrib (tlcontrib) 3 | https://ftp.math.utah.edu/pub/tex/historic/systems/texlive/2022/tlnet-final (main) 4 | http://www.texlive.info/tlgpg/ 5 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlmgr-setup_one_remotetlpdb-ctan.stderr: -------------------------------------------------------------------------------- 1 | 2 | tlmgr: Local TeX Live (2022) is older than remote repository (2023). 3 | Cross release updates are only supported with 4 | update-tlmgr-latest(.sh/.exe) --update 5 | See https://tug.org/texlive/upgrade.html for details. 6 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlmgr-setup_one_remotetlpdb-tlcontrib.stderr: -------------------------------------------------------------------------------- 1 | TeX Live 2022 is frozen 2 | and will no longer be routinely updated. This happens when a new 3 | release is made, or will be made shortly. 4 | 5 | For general status information about TeX Live, see its home page: 6 | https://tug.org/texlive 7 | 8 | 9 | tlmgr: The TeX Live versions supported by the repository 10 | http://ftp.dante.de/tex-archive/systems/texlive/tlcontrib 11 | (2023--2100) 12 | do not include the version of the local installation 13 | (2022). 14 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlpkg-check_file_and_remove.stderr: -------------------------------------------------------------------------------- 1 | TeXLive::TLUtils::check_file_and_remove: checksums differ for /tmp/lMXGyR8gDe/UkZgdaw6pk/babel.tar.xz: 2 | TeXLive::TLUtils::check_file_and_remove: tlchecksum=e616c6d12a6f0648309e63044b342bd5cf09a0950e062960f47daec364ffa9e8d33df2851ed5bfc03d66e7e264046475fcad5fbc2d553fb21bc37279eabaa5d6, arg=3863ba77af35af465d47accdcc59f57c2f8a1d522b12a34d036444e0851e9e6b5020ef94452ecde206deaa3420824897289e60093db2f88973ce47774d6958b5 3 | TeXLive::TLUtils::check_file_and_remove: backtrace: 4 | -> /home/runner/work/_temp/setup-texlive/2022/tlpkg/TeXLive/TLUtils.pm:2590: TeXLive::TLUtils::check_file_and_remove 5 | -> /home/runner/work/_temp/setup-texlive/2022/tlpkg/TeXLive/TLPDB.pm:1983: TeXLive::TLUtils::unpack 6 | -> /home/runner/work/_temp/setup-texlive/2022/tlpkg/TeXLive/TLPDB.pm:1818: TeXLive::TLPDB::_install_data 7 | -> /home/runner/work/_temp/setup-texlive/2022/tlpkg/TeXLive/TLPDB.pm:1734: TeXLive::TLPDB::not_virtual_install_package 8 | -> /home/runner/work/_temp/setup-texlive/2022/bin/x86_64-linux/tlmgr:3921: TeXLive::TLPDB::install_package 9 | -> /home/runner/work/_temp/setup-texlive/2022/bin/x86_64-linux/tlmgr:797: main::action_install 10 | -> /home/runner/work/_temp/setup-texlive/2022/bin/x86_64-linux/tlmgr:703: main::execute_action 11 | -> /home/runner/work/_temp/setup-texlive/2022/bin/x86_64-linux/tlmgr:366: main::main 12 | TeXLive::TLUtils::check_file_and_remove: removing /tmp/lMXGyR8gDe/UkZgdaw6pk/babel.tar.xz, but saving copy in /tmp/qAuQC6KJf3 13 | TLPDB::_install_data: downloading did not succeed (check_file_and_remove failed) for https://mirror.mwt.me/ctan/systems/texlive/tlnet/archive/babel.tar.xz 14 | -------------------------------------------------------------------------------- /packages/fixtures/data/tlpkg-tlpdb_from_file.stderr: -------------------------------------------------------------------------------- 1 | 2 | /usr/local/texlive/2023/bin/x86_64-linux/tlmgr: TLPDB::from_file could not initialize from: https://ftp.math.utah.edu/pub/tex/historic/systems/texlive/2024/tlnet-final/tlpkg/texlive.tlpdb 3 | /usr/local/texlive/2023/bin/x86_64-linux/tlmgr: Maybe the repository setting should be changed. 4 | /usr/local/texlive/2023/bin/x86_64-linux/tlmgr: More info: https://tug.org/texlive/acquire.html 5 | -------------------------------------------------------------------------------- /packages/fixtures/index.mjs: -------------------------------------------------------------------------------- 1 | import { extname } from 'node:path'; 2 | 3 | /** 4 | * Load fixture files as a module. 5 | * @returns {import('vite').Plugin} 6 | */ 7 | export default function fixtures() { 8 | return { 9 | name: '@setup-texlive-action/fixtures', 10 | async transform(src, id) { 11 | switch (extname(id)) { 12 | case '.http': 13 | return { 14 | code: ` 15 | import parseHttp from 'http-headers'; 16 | 17 | const http = parseHttp(${JSON.stringify(src)}); 18 | 19 | export const { 20 | version, 21 | statusCode, 22 | statusMessage, 23 | headers, 24 | } = http; 25 | export default http; 26 | `, 27 | }; 28 | case '.log': 29 | case '.stderr': 30 | case '.stdout': 31 | case '.tlpdb': 32 | return { 33 | code: ` 34 | export default ${JSON.stringify(src)}; 35 | `, 36 | }; 37 | default: 38 | return undefined; 39 | } 40 | }, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/fixtures/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setup-texlive-action/fixtures", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": {}, 7 | "exports": { 8 | ".": "./index.mjs", 9 | "./*.json": { 10 | "import": "./data/*.json" 11 | }, 12 | "./*.http": { 13 | "import": "./data/*.http", 14 | "types": "./types/http.d.ts" 15 | }, 16 | "./*": { 17 | "import": "./data/*", 18 | "types": "./types/txt.d.ts" 19 | } 20 | }, 21 | "devDependencies": { 22 | "@types/http-headers": "^3.0.3", 23 | "http-headers": "^3.0.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/fixtures/types/http.d.ts: -------------------------------------------------------------------------------- 1 | import type { ResponseData } from 'http-headers'; 2 | 3 | declare var http: ResponseData; 4 | 5 | export const { version, statusCode, statusMessage, headers }: ResponseData; 6 | export default http; 7 | -------------------------------------------------------------------------------- /packages/fixtures/types/txt.d.ts: -------------------------------------------------------------------------------- 1 | declare var txt: string; 2 | export default txt; 3 | -------------------------------------------------------------------------------- /packages/logger/__mocks__/process.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, vi } from 'vitest'; 2 | 3 | beforeEach(() => { 4 | for (const key of Object.keys(globalThis.process.env)) { 5 | delete globalThis.process.env[key]; 6 | } 7 | vi.stubEnv('RUNNER_TEMP', ''); 8 | vi.stubEnv('RUNNER_DEBUG', '0'); 9 | }); 10 | 11 | export const { env } = globalThis.process; 12 | export const stdout = { 13 | hasColors: vi.fn().mockResolvedValue(false), 14 | }; 15 | export default { env, stdout }; 16 | -------------------------------------------------------------------------------- /packages/logger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setup-texlive-action/logger", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "test": "vitest" 8 | }, 9 | "exports": { 10 | ".": "./src/index.ts", 11 | "./custom-inspect": "./src/custom-inspect.ts" 12 | }, 13 | "sideEffects": [ 14 | "./src/custom-inspect.ts" 15 | ], 16 | "dependencies": { 17 | "@actions/core": "^1.11.0", 18 | "ansi-styles": "^6.2.1", 19 | "clean-stack": "^5.2.0", 20 | "ts-pattern": "^5.7.1" 21 | }, 22 | "devDependencies": { 23 | "@setup-texlive-action/polyfill": "*", 24 | "@setup-texlive-action/utils": "*" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/logger/src/index.ts: -------------------------------------------------------------------------------- 1 | export { group } from '@actions/core'; 2 | 3 | export * from './log.js'; 4 | export { default as styles, hasColors } from './styles.js'; 5 | export * as symbols from './symbols.js'; 6 | -------------------------------------------------------------------------------- /packages/logger/src/styles.ts: -------------------------------------------------------------------------------- 1 | import { env, stdout } from 'node:process'; 2 | 3 | import { isDebug } from '@actions/core'; 4 | import ansi, { 5 | type CSPair, 6 | type ForegroundColorName, 7 | type ModifierName, 8 | } from 'ansi-styles'; 9 | 10 | // eslint-disable-next-line jsdoc/require-jsdoc 11 | export function hasColors(): boolean { 12 | if (env.GITHUB_ACTIONS === 'true' && !('ACT' in env)) { 13 | return !isDebug() && (env.NO_COLOR ?? '') === ''; 14 | } 15 | // `internal.tty.hasColors` supports `NO_COLOR` (https://no-color.org/), 16 | // but its handling of empty strings does not conform to the spec. 17 | // https://github.com/nodejs/node/blob/v21.0.0/lib/internal/tty.js#L129 18 | if (env.NO_COLOR === '') { 19 | delete env.NO_COLOR; 20 | } 21 | // `process.stdout` is not necessarily an instance of `tty.WriteStream`. 22 | return (stdout as Partial).hasColors?.() ?? false; 23 | } 24 | 25 | function stylize( 26 | style: ForegroundColorName | ModifierName, 27 | ): (input: string | TemplateStringsArray) => string { 28 | const group = style in ansi.modifier ? ansi.modifier : ansi.color; 29 | const { open, close } = group[style as keyof typeof group] as CSPair; 30 | return (input) => { 31 | const text = (Array.isArray(input) ? input[0] : input) as string; 32 | return hasColors() ? `${open}${text}${close}` : text; 33 | }; 34 | } 35 | 36 | export default { 37 | dim: stylize('dim'), 38 | red: stylize('red'), 39 | blue: stylize('blue'), 40 | }; 41 | -------------------------------------------------------------------------------- /packages/logger/src/symbols.ts: -------------------------------------------------------------------------------- 1 | export const note = Symbol('note'); 2 | 3 | declare global { 4 | interface Error { 5 | [note]?: string; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/logger/tsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", 3 | "extends": ["@setup-texlive-action/config/tsdoc.json"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/logger/vitest.config.mjs: -------------------------------------------------------------------------------- 1 | export { default } from '@setup-texlive-action/config/vitest'; 2 | -------------------------------------------------------------------------------- /packages/polyfill/README.md: -------------------------------------------------------------------------------- 1 | # @setup-texlive-action/polyfill 2 | 3 | > Polyfills for some [ECMAScript proposals](https://www.proposals.es) 4 | -------------------------------------------------------------------------------- /packages/polyfill/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setup-texlive-action/polyfill", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": {}, 7 | "main": "src/index.ts", 8 | "sideEffects": true, 9 | "dependencies": { 10 | "@abraham/reflection": "^0.13.0", 11 | "array-from-async": "^3.0.0", 12 | "temporal-polyfill": "^0.3.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/polyfill/src/array-from-async.ts: -------------------------------------------------------------------------------- 1 | import fromAsync from 'array-from-async'; 2 | 3 | if (typeof Array.fromAsync !== 'function') { 4 | Object.defineProperty(Array, 'fromAsync', { 5 | value: fromAsync, 6 | configurable: false, 7 | enumerable: false, 8 | writable: false, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /packages/polyfill/src/disposable.ts: -------------------------------------------------------------------------------- 1 | for (const name of ['dispose', 'asyncDispose'] as const) { 2 | if (typeof Symbol[name] !== 'symbol') { 3 | Object.defineProperty(Symbol, name, { 4 | value: Symbol.for(`nodejs.${name}`), 5 | configurable: false, 6 | enumerable: false, 7 | writable: false, 8 | }); 9 | } 10 | } 11 | 12 | declare global { 13 | interface SymbolConstructor { 14 | readonly asyncDispose: unique symbol; 15 | readonly dispose: unique symbol; 16 | } 17 | 18 | interface Disposable { 19 | [Symbol.dispose](): void; 20 | } 21 | 22 | interface AsyncDisposable { 23 | [Symbol.asyncDispose](): PromiseLike; 24 | } 25 | 26 | interface SuppressedError extends Error { 27 | error: unknown; 28 | suppressed: unknown; 29 | } 30 | 31 | interface SuppressedErrorConstructor { 32 | /* eslint-disable-next-line 33 | @typescript-eslint/prefer-readonly-parameter-types */ 34 | new(...args: unknown[]): SuppressedError; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/polyfill/src/index.ts: -------------------------------------------------------------------------------- 1 | import '@abraham/reflection'; 2 | import 'temporal-polyfill/global'; 3 | 4 | import './array-from-async.js'; 5 | import './disposable.js'; 6 | -------------------------------------------------------------------------------- /packages/rspack.config.mjs: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | import esbuildConfig, { transformConfig } from './config/esbuild.config.mjs'; 5 | import pluginLicenses from './config/rspack/plugin-licenses.mjs'; 6 | 7 | env['FORCE_COLOR'] = '1'; 8 | 9 | /** @type {import('@rspack/cli').Configuration} */ 10 | export default { 11 | entry: './action', 12 | output: { 13 | path: '../dist', 14 | }, 15 | resolve: { 16 | conditionNames: esbuildConfig.conditions ?? [], 17 | extensions: esbuildConfig.resolveExtensions ?? [], 18 | extensionAlias: { 19 | '.js': ['.ts', '.js'], 20 | }, 21 | mainFields: esbuildConfig.mainFields ?? [], 22 | tsConfig: fileURLToPath(import.meta.resolve('./tsconfig.json')), 23 | }, 24 | mode: 'none', 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.(ts|json)/v, 29 | loader: 'esbuild-loader', 30 | options: transformConfig, 31 | type: 'javascript/auto', 32 | }, 33 | ], 34 | }, 35 | target: transformConfig.target, 36 | externals: Object.keys(esbuildConfig.alias ?? {}), 37 | optimization: { 38 | minimize: false, 39 | }, 40 | plugins: [pluginLicenses], 41 | stats: { 42 | preset: 'errors-warnings', 43 | colors: true, 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /packages/texlive/__mocks__/@actions/core.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | const actual = await vi.importActual( 4 | '@actions/core', 5 | ); 6 | 7 | export const addPath = vi.fn(); 8 | export const debug = vi.fn(); 9 | export const error = vi.fn(); 10 | export const exportVariable = vi.fn(); 11 | export const getBooleanInput = vi.fn(actual.getBooleanInput); 12 | export const getInput = vi.fn(actual.getInput); 13 | export const getState = vi.fn().mockResolvedValue(''); 14 | export const group = vi.fn(async (name, fn) => await fn()); 15 | export const info = vi.fn(); 16 | export const isDebug = vi.fn().mockReturnValue(false); 17 | export const notice = vi.fn(); 18 | export const saveState = vi.fn(); 19 | export const setFailed = vi.fn((error) => { 20 | throw new Error(`${error}`); 21 | }); 22 | export const setOutput = vi.fn(); 23 | export const warning = vi.fn(); 24 | -------------------------------------------------------------------------------- /packages/texlive/__mocks__/@actions/exec.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const exec = vi.fn(); 4 | export const getExecOutput = vi.fn().mockResolvedValue({ 5 | exitCode: 0, 6 | stdout: '', 7 | stderr: '', 8 | }); 9 | -------------------------------------------------------------------------------- /packages/texlive/__mocks__/@actions/tool-cache.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const downloadTool = vi.fn().mockResolvedValue(''); 4 | export const find = vi.fn().mockReturnValue(''); 5 | export const cacheDir = vi.fn(); 6 | export const extractTar = vi.fn(); 7 | export const extractZip = vi.fn(); 8 | -------------------------------------------------------------------------------- /packages/texlive/__mocks__/fs/promises.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const readFile = vi.fn().mockResolvedValue(''); 4 | export const readdir = vi.fn().mockResolvedValue(['']); 5 | export const writeFile = vi.fn(); 6 | -------------------------------------------------------------------------------- /packages/texlive/__mocks__/os.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const EOL = '\n'; 4 | export const arch = vi.fn().mockReturnValue(''); 5 | export const homedir = vi.fn().mockReturnValue('~'); 6 | export const platform = vi.fn().mockReturnValue('linux'); 7 | export const tmpdir = vi.fn().mockReturnValue(''); 8 | -------------------------------------------------------------------------------- /packages/texlive/__mocks__/path.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | import * as os from 'node:os'; 4 | 5 | export const { posix, win32 } = await vi.importActual< 6 | typeof import('node:path') 7 | >('node:path'); 8 | 9 | function getPath(): any { 10 | return os.platform() === 'win32' ? win32 : posix; 11 | } 12 | 13 | export const basename = vi.fn((...args) => getPath().basename(...args)); 14 | export const format = vi.fn((...args) => getPath().format(...args)); 15 | export const join = vi.fn((...args) => getPath().join(...args)); 16 | export const normalize = vi.fn((...args) => getPath().normalize(...args)); 17 | export const resolve = vi.fn((...args) => getPath().resolve(...args)); 18 | -------------------------------------------------------------------------------- /packages/texlive/__mocks__/process.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, vi } from 'vitest'; 2 | 3 | beforeEach(() => { 4 | for (const key of Object.keys(globalThis.process.env)) { 5 | delete globalThis.process.env[key]; 6 | } 7 | vi.stubEnv('RUNNER_TEMP', ''); 8 | vi.stubEnv('RUNNER_DEBUG', '0'); 9 | }); 10 | 11 | export const { env } = globalThis.process; 12 | export const stdout = { 13 | hasColors: vi.fn().mockResolvedValue(false), 14 | }; 15 | export default { env, stdout }; 16 | -------------------------------------------------------------------------------- /packages/texlive/__mocks__/unctx.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, vi } from 'vitest'; 2 | 3 | import type { UseContext } from 'unctx'; 4 | 5 | const unctx = await vi.importActual('unctx'); 6 | 7 | export function createContext(): UseContext { 8 | const ctx = unctx.createContext(); 9 | afterEach(ctx.unset); 10 | return ctx; 11 | } 12 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/ctan.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; 2 | 3 | import { 4 | headers, 5 | statusCode, 6 | } from '@setup-texlive-action/fixtures/mirrors.ctan.org.http'; 7 | import nock, { type ReplyHeaders } from 'nock'; 8 | 9 | import * as ctan from '#texlive/ctan'; 10 | 11 | beforeAll(async () => { 12 | nock('https://mirrors.ctan.org') 13 | .head('/') 14 | .reply(statusCode, '', headers as ReplyHeaders); 15 | }); 16 | 17 | afterAll(nock.restore); 18 | 19 | vi.unmock('@actions/http-client'); 20 | vi.unmock('#texlive/ctan/mirrors'); 21 | 22 | describe('mirrors.resolve', () => { 23 | const mirror = new URL('https://ctan.math.washington.edu/tex-archive/'); 24 | 25 | it('resolves location', async () => { 26 | await expect(ctan.mirrors.resolve()).resolves.toStrictEqual(mirror); 27 | expect(nock.isDone()).toBe(true); 28 | }); 29 | 30 | it('does not send a request twice', async () => { 31 | await expect(ctan.mirrors.resolve()).resolves.toStrictEqual(mirror); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/install-tl/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | 3 | import { readFile } from 'node:fs/promises'; 4 | import * as os from 'node:os'; 5 | 6 | import * as core from '@actions/core'; 7 | import * as tool from '@actions/tool-cache'; 8 | import releaseText from '@setup-texlive-action/fixtures/release-texlive.txt?raw'; 9 | import * as util from '@setup-texlive-action/utils'; 10 | 11 | import { acquire, restoreCache } from '#texlive/install-tl/cli'; 12 | 13 | vi.unmock('#texlive/install-tl/cli'); 14 | 15 | const fail = (): any => { 16 | throw new Error(); 17 | }; 18 | 19 | const options = { 20 | version: '2023', 21 | repository: new URL(MOCK_URL), 22 | } as const; 23 | 24 | beforeAll(() => { 25 | vi.mocked(readFile).mockResolvedValue(releaseText); 26 | }); 27 | 28 | beforeEach(() => { 29 | vi.mocked(os.platform).mockReturnValue('linux'); 30 | }); 31 | 32 | describe('restore', () => { 33 | it('uses cache if available', () => { 34 | vi.mocked(tool.find).mockReturnValueOnce(''); 35 | expect(restoreCache(options.version)).toBeDefined(); 36 | }); 37 | 38 | it('returns undefined if cache not found', () => { 39 | expect(restoreCache(options.version)).toBeUndefined(); 40 | }); 41 | 42 | it('does not fail even if tool.find fails', () => { 43 | vi.mocked(tool.find).mockImplementationOnce(fail); 44 | expect(restoreCache(options.version)).toBeUndefined(); 45 | expect(core.info).toHaveBeenCalledTimes(2); 46 | expect(vi.mocked(core.info).mock.calls[1]?.[0]).toMatchInlineSnapshot( 47 | '"Failed to restore install-tl: Error"', 48 | ); 49 | }); 50 | }); 51 | 52 | describe('acquire', () => { 53 | it('downloads installer', async () => { 54 | await acquire(options); 55 | expect(tool.downloadTool).toHaveBeenCalled(); 56 | expect(util.extract).toHaveBeenCalled(); 57 | }); 58 | 59 | it('saves installer to cache', async () => { 60 | await acquire(options); 61 | expect(tool.cacheDir).toHaveBeenCalled(); 62 | }); 63 | 64 | it('does not fail even if tool.cacheDir fails', async () => { 65 | vi.mocked(tool.cacheDir).mockImplementationOnce(fail); 66 | await expect(acquire(options)).resolves.not.toThrow(); 67 | expect(core.info).toHaveBeenCalledTimes(5); 68 | expect(vi.mocked(core.info).mock.calls[0]?.[0]).toMatchInlineSnapshot( 69 | '"Downloading install-tl-unx.tar.gz from https://example.com/install-tl-unx.tar.gz"', 70 | ); 71 | expect(vi.mocked(core.info).mock.calls[1]?.[0]).toMatchInlineSnapshot( 72 | '"Extracting install-tl from "', 73 | ); 74 | expect(vi.mocked(core.info).mock.calls[2]?.[0]).toMatchInlineSnapshot( 75 | '"Adding to tool cache"', 76 | ); 77 | expect(vi.mocked(core.info).mock.calls[4]?.[0]).toMatchInlineSnapshot( 78 | '"Failed to cache install-tl: Error"', 79 | ); 80 | }); 81 | 82 | it('infers version', async () => { 83 | await expect(acquire({ repository: options.repository })) 84 | .resolves 85 | .toHaveProperty('version', options.version); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/install-tl/profile.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, beforeEach, describe, expect, it, test, vi } from 'vitest'; 2 | 3 | import * as os from 'node:os'; 4 | 5 | import * as rawSerializer from '@setup-texlive-action/config/vitest/raw-serializer.js'; 6 | 7 | import { Profile } from '#texlive/install-tl/profile'; 8 | import type { Version } from '#texlive/version'; 9 | 10 | vi.unmock('#texlive/install-tl/profile'); 11 | 12 | const opts = { prefix: '' }; 13 | 14 | beforeEach(() => { 15 | vi.mocked(os.arch).mockReturnValue('x64'); 16 | }); 17 | 18 | describe('selected_scheme', () => { 19 | it('uses scheme-infraonly by default', () => { 20 | const profile = new Profile(LATEST_VERSION, opts); 21 | expect(profile.selectedScheme).toBe('scheme-infraonly'); 22 | }); 23 | 24 | it.each(['2008', '2011', '2014'] as const)( 25 | 'uses scheme-minimal for versions prior to 2016', 26 | (version) => { 27 | const profile = new Profile(version, opts); 28 | expect(profile.selectedScheme).toBe('scheme-minimal'); 29 | }, 30 | ); 31 | }); 32 | 33 | describe('instopt_adjustrepo', () => { 34 | it('is set to false even for the latest version', () => { 35 | const profile = new Profile(LATEST_VERSION, opts); 36 | expect(profile.instopt.adjustrepo).toBe(false); 37 | }); 38 | 39 | it.each(['2008', '2012', '2016', '2020'] as const)( 40 | 'is set to false for an older version', 41 | (version) => { 42 | const profile = new Profile(version, opts); 43 | expect(profile.instopt.adjustrepo).toBe(false); 44 | }, 45 | ); 46 | }); 47 | 48 | describe('binary', () => { 49 | it.each( 50 | ['linux', 'win32', 'darwin'] as const, 51 | )('is unspecified by default', (platform) => { 52 | vi.mocked(os.platform).withImplementation(() => platform, () => { 53 | const profile = new Profile(LATEST_VERSION, opts); 54 | expect(profile).not.toHaveProperty('binary'); 55 | }); 56 | }); 57 | 58 | it('uses universal-darwin on apple silicon', () => { 59 | vi.mocked(os.arch).mockReturnValue('arm64'); 60 | vi.mocked(os.platform).withImplementation(() => 'darwin', () => { 61 | const profile = new Profile('2019', opts); 62 | expect(profile).toHaveProperty('binary', 'universal-darwin'); 63 | }); 64 | }); 65 | }); 66 | 67 | describe.each([ 68 | ...(function*(): Generator { 69 | for (let year = 2008; year <= Number.parseInt(LATEST_VERSION); ++year) { 70 | yield `${year}` as Version; 71 | } 72 | })(), 73 | ])('%s', (version) => { 74 | beforeAll(async () => { 75 | expect.addSnapshotSerializer(rawSerializer); 76 | }); 77 | 78 | describe.each(['linux', 'win32'] as const)('%s', (platform) => { 79 | test('texlive.profile', () => { 80 | const prefix = '$RUNNER_TEMP/setup-texlive-action'; 81 | vi.mocked(os.platform).mockReturnValue(platform); 82 | expect(new Profile(version, { prefix }).toString()).toMatchSnapshot(); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/releases.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; 2 | 3 | import * as core from '@actions/core'; 4 | import json from '@setup-texlive-action/fixtures/ctan-api-pkg-texlive.json' with { 5 | type: 'json', 6 | }; 7 | import nock from 'nock'; 8 | 9 | import { Latest, ReleaseData } from '#texlive/releases'; 10 | 11 | vi.unmock('@actions/http-client'); 12 | vi.unmock('#texlive/releases'); 13 | 14 | let doMock: () => nock.Scope; 15 | 16 | beforeAll(async () => { 17 | json.version.number = LATEST_VERSION; 18 | doMock = () => { 19 | return nock('https://ctan.org') 20 | .get('/json/2.0/pkg/texlive') 21 | .query(true) 22 | .reply(200, json); 23 | }; 24 | }); 25 | 26 | afterAll(nock.restore); 27 | 28 | describe('LatestRelease', () => { 29 | describe('checkVersion', () => { 30 | it('checks for latest version using the CTAN API', async () => { 31 | const mock = doMock(); 32 | await expect(new Latest().checkVersion()).resolves.toBe(LATEST_VERSION); 33 | expect(mock.isDone()).toBe(true); 34 | }); 35 | 36 | it('throws no exception', async () => { 37 | await expect(new Latest().checkVersion()).resolves.not.toThrow(); 38 | expect(core.info).toHaveBeenCalledWith( 39 | expect.stringContaining('Failed to check'), 40 | ); 41 | }); 42 | }); 43 | }); 44 | 45 | describe('ReleaseData.setup', () => { 46 | let mock: nock.Scope; 47 | beforeAll(() => { 48 | mock = doMock(); 49 | }); 50 | 51 | it('does not usually check for the latest version', async () => { 52 | await expect(ReleaseData.setup()).resolves.not.toThrow(); 53 | expect(mock.isDone()).toBe(false); 54 | }); 55 | 56 | it('checks for the latest version if needed', async () => { 57 | vi.spyOn(Temporal.Now, 'instant').mockReturnValueOnce( 58 | Temporal 59 | .PlainDateTime 60 | .from('2026-03-07') 61 | .toZonedDateTime('UTC') 62 | .toInstant(), 63 | ); 64 | await expect(ReleaseData.setup()).resolves.not.toThrow(); 65 | expect(mock.isDone()).toBe(true); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | import versions from '@setup-texlive-action/data/texlive-versions.json' with { 4 | type: 'json', 5 | }; 6 | import '@setup-texlive-action/polyfill'; 7 | 8 | import type { Version } from '#texlive/version'; 9 | 10 | vi.mock('fs/promises'); 11 | vi.mock('os'); 12 | vi.mock('path'); 13 | vi.mock('process'); 14 | vi.mock('@actions/core'); 15 | vi.mock('@actions/exec'); 16 | vi.mock('@actions/http-client'); 17 | vi.mock('@actions/io'); 18 | vi.mock('@actions/tool-cache'); 19 | vi.mock('@setup-texlive-action/utils'); 20 | vi.mock('unctx'); 21 | vi.mock('#texlive/ctan/mirrors'); 22 | vi.mock('#texlive/install-tl/cli'); 23 | vi.mock('#texlive/install-tl/profile'); 24 | vi.mock('#texlive/releases'); 25 | vi.mock('#texlive/tex/kpse'); 26 | vi.mock('#texlive/tlmgr/actions/conf'); 27 | vi.mock('#texlive/tlmgr/actions/install'); 28 | vi.mock('#texlive/tlmgr/actions/list'); 29 | vi.mock('#texlive/tlmgr/actions/path'); 30 | vi.mock('#texlive/tlmgr/actions/pinning'); 31 | vi.mock('#texlive/tlmgr/actions/repository'); 32 | vi.mock('#texlive/tlmgr/actions/update'); 33 | vi.mock('#texlive/tlmgr/actions/version'); 34 | vi.mock('#texlive/tlmgr/internals'); 35 | vi.mock('#texlive/tlnet'); 36 | vi.mock('#texlive/tlpkg/patch'); 37 | vi.mock('#texlive/tlpkg/util'); 38 | 39 | vi.stubGlobal('LATEST_VERSION', versions.current.version as Version); 40 | // https://www.rfc-editor.org/rfc/rfc2606.html 41 | vi.stubGlobal('MOCK_URL', 'https://example.com/'); 42 | 43 | declare global { 44 | var LATEST_VERSION: Version; 45 | var MOCK_URL: string; 46 | } 47 | 48 | export async function acquire(): Promise { 49 | return { 50 | run: vi.fn(), 51 | }; 52 | } 53 | /* eslint no-var: off */ 54 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/tlmgr/conf.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { exportVariable } from '@actions/core'; 4 | import { exec } from '@setup-texlive-action/utils'; 5 | 6 | import * as conf from '#texlive/tlmgr/actions/conf'; 7 | import { TlmgrInternals, set } from '#texlive/tlmgr/internals'; 8 | import { makeLocalSkeleton } from '#texlive/tlpkg'; 9 | import type { Version } from '#texlive/version'; 10 | 11 | vi.unmock('#texlive/tlmgr/actions/conf'); 12 | 13 | const setVersion = (version: Version) => { 14 | set(new TlmgrInternals({ TEXDIR: '', version }), true); 15 | }; 16 | 17 | describe('texmf', () => { 18 | it('returns the value of the given key by using `kpsewhich`', async () => { 19 | setVersion('2021'); 20 | await expect(conf.texmf('TEXMFCONFIG')).resolves.toBe(''); 21 | }); 22 | 23 | it('sets the value to the given key with `tlmgr`', async () => { 24 | setVersion('2021'); 25 | await conf.texmf('TEXMFVAR', '~/.local/texlive/2021/texmf-var'); 26 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith( 27 | 'conf', 28 | expect.anything(), 29 | ); 30 | expect(exportVariable).not.toHaveBeenCalled(); 31 | }); 32 | 33 | it('sets the value to the given key by environment variable', async () => { 34 | setVersion('2008'); 35 | await conf.texmf('TEXMFHOME', '~/.texmf'); 36 | expect(TlmgrInternals.prototype.exec).not.toHaveBeenCalled(); 37 | expect(exportVariable).toHaveBeenCalledWith('TEXMFHOME', '~/.texmf'); 38 | }); 39 | 40 | it('initializes TEXMFLOCAL if it is changed', async () => { 41 | setVersion(LATEST_VERSION); 42 | await conf.texmf('TEXMFLOCAL', ''); 43 | expect(makeLocalSkeleton).toHaveBeenCalledWith( 44 | '', 45 | expect.anything(), 46 | ); 47 | expect(exec).toHaveBeenCalledWith('mktexlsr', ['']); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/tlmgr/list.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, expect, it, vi } from 'vitest'; 2 | 3 | import { readFile } from 'node:fs/promises'; 4 | 5 | import tlpdb2008 from '@setup-texlive-action/fixtures/texlive.2008.tlpdb'; 6 | import tlpdb2023 from '@setup-texlive-action/fixtures/texlive.2023.tlpdb'; 7 | 8 | import { list } from '#texlive/tlmgr/actions/list'; 9 | import { TlmgrInternals, set } from '#texlive/tlmgr/internals'; 10 | import type { TLPObj } from '#texlive/tlpkg'; 11 | 12 | vi.unmock('#texlive/tlmgr/actions/list'); 13 | 14 | const years = ['2008', '2023'] as const; 15 | const tlpdb = { 16 | '2008': new Set(), 17 | '2023': new Set(), 18 | } as const; 19 | 20 | beforeAll(async () => { 21 | for (const year of years) { 22 | set(new TlmgrInternals({ TEXDIR: '', version: year }), true); 23 | vi.mocked(readFile).mockResolvedValueOnce( 24 | year === '2008' ? tlpdb2008 : tlpdb2023, 25 | ); 26 | for await (const tlpobj of list()) { 27 | tlpdb[year].add(tlpobj); 28 | } 29 | } 30 | }); 31 | 32 | it('lists texlive.infra', () => { 33 | expect(tlpdb['2008']).toContainEqual( 34 | expect.objectContaining({ 35 | name: 'texlive.infra', 36 | revision: '12186', 37 | cataloguedata: { version: undefined }, 38 | }), 39 | ); 40 | expect(tlpdb['2023']).toContainEqual( 41 | expect.objectContaining({ 42 | name: 'texlive.infra', 43 | revision: '66822', 44 | cataloguedata: { version: undefined }, 45 | }), 46 | ); 47 | }); 48 | 49 | it('does not list schemes and collections', () => { 50 | expect(tlpdb['2008']).not.toContainEqual( 51 | expect.objectContaining({ name: 'scheme-minimal' }), 52 | ); 53 | expect(tlpdb['2008']).not.toContainEqual( 54 | expect.objectContaining({ name: 'collection-basic' }), 55 | ); 56 | expect(tlpdb['2023']).not.toContainEqual( 57 | expect.objectContaining({ name: 'scheme-infraonly' }), 58 | ); 59 | }); 60 | 61 | it.each(years)('does not list architecture-specific packages (%s)', (year) => { 62 | expect(tlpdb[year]).not.toContainEqual( 63 | expect.objectContaining({ name: 'kpathsea.x86_64-linux' }), 64 | ); 65 | expect(tlpdb[year]).not.toContainEqual( 66 | expect.objectContaining({ name: 'texlive.infra.x86_64-linux' }), 67 | ); 68 | }); 69 | 70 | it('does not list texlive metadata', () => { 71 | expect(tlpdb['2008']).not.toContainEqual( 72 | expect.objectContaining({ name: '00texlive-installation.config' }), 73 | ); 74 | expect(tlpdb['2023']).not.toContainEqual( 75 | expect.objectContaining({ name: '00texlive.config' }), 76 | ); 77 | }); 78 | 79 | it('lists normal packages', () => { 80 | expect(tlpdb['2008']).toContainEqual( 81 | expect.objectContaining({ 82 | name: 'pdftex', 83 | revision: '12898', 84 | cataloguedata: { version: '1.40.9' }, 85 | }), 86 | ); 87 | expect(tlpdb['2023']).toContainEqual( 88 | expect.objectContaining({ 89 | name: 'hyphen-base', 90 | revision: '66413', 91 | cataloguedata: { version: undefined }, 92 | }), 93 | ); 94 | }); 95 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/tlmgr/path.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 | 3 | import { addPath } from '@actions/core'; 4 | import { uniqueChild } from '@setup-texlive-action/utils'; 5 | 6 | import * as path from '#texlive/tlmgr/actions/path'; 7 | import { TlmgrInternals, set } from '#texlive/tlmgr/internals'; 8 | 9 | vi.unmock('#texlive/tlmgr/actions/path'); 10 | 11 | describe('add', () => { 12 | beforeEach(() => { 13 | set(new TlmgrInternals({ TEXDIR: '', version: LATEST_VERSION })); 14 | }); 15 | 16 | it('adds the bin directory to the PATH', async () => { 17 | vi.mocked(uniqueChild).mockResolvedValueOnce(''); 18 | await path.add(); 19 | expect(uniqueChild).toHaveBeenCalledWith('/bin'); 20 | expect(addPath).toHaveBeenCalledWith(''); 21 | }); 22 | 23 | it('fails as the bin directory cannot be located', async () => { 24 | vi.mocked(uniqueChild).mockImplementationOnce(() => { 25 | throw new Error(); 26 | }); 27 | await expect(path.add()).rejects.toThrow( 28 | "Unable to locate TeX Live's binary directory", 29 | ); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/tlmgr/pinning.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import * as pinning from '#texlive/tlmgr/actions/pinning'; 4 | import { TlmgrInternals, set } from '#texlive/tlmgr/internals'; 5 | import type { Version } from '#texlive/version'; 6 | 7 | vi.unmock('#texlive/tlmgr/actions/pinning'); 8 | 9 | const setVersion = (version: Version) => { 10 | set(new TlmgrInternals({ TEXDIR: '', version }), true); 11 | }; 12 | 13 | describe('add', () => { 14 | it('pins a repository with a glob', async () => { 15 | setVersion('2019'); 16 | await pinning.add('', '*'); 17 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith( 18 | 'pinning', 19 | expect.anything(), 20 | ); 21 | }); 22 | 23 | it('pins a repository with globs', async () => { 24 | setVersion('2019'); 25 | await pinning.add('', '', ''); 26 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith( 27 | 'pinning', 28 | expect.anything(), 29 | ); 30 | }); 31 | 32 | it('fails since the `pinning` action is not implemented', async () => { 33 | setVersion('2012'); 34 | await expect(pinning.add('', '*')).rejects.toThrow(/./v); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/tlmgr/repository.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import stderr from '@setup-texlive-action/fixtures/tlmgr-repository-add.stderr'; 4 | import { ExecError } from '@setup-texlive-action/utils'; 5 | 6 | import * as repository from '#texlive/tlmgr/actions/repository'; 7 | import { TlmgrInternals, set } from '#texlive/tlmgr/internals'; 8 | import type { Version } from '#texlive/version'; 9 | 10 | vi.unmock('#texlive/tlmgr/actions/repository'); 11 | 12 | const setVersion = (version: Version) => { 13 | set(new TlmgrInternals({ TEXDIR: '', version }), true); 14 | }; 15 | 16 | describe('add', () => { 17 | it('adds a repository with a tag', async () => { 18 | setVersion('2019'); 19 | await expect(repository.add('', '')) 20 | .resolves 21 | .not 22 | .toThrow(); 23 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith( 24 | 'repository', 25 | expect.anything(), 26 | ); 27 | }); 28 | 29 | it('adds a repository with the empty tag', async () => { 30 | setVersion('2019'); 31 | await expect(repository.add('', '')).resolves.not.toThrow(); 32 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith( 33 | 'repository', 34 | expect.anything(), 35 | ); 36 | }); 37 | 38 | it('adds a repository with no tags', async () => { 39 | setVersion('2019'); 40 | await expect(repository.add('')).resolves.not.toThrow(); 41 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith( 42 | 'repository', 43 | expect.anything(), 44 | ); 45 | }); 46 | 47 | it('can safely add the repository again', async () => { 48 | vi.mocked(TlmgrInternals.prototype.exec).mockRejectedValueOnce( 49 | new ExecError({ 50 | command: 'tlmgr', 51 | exitCode: 2, 52 | stdout: '', 53 | stderr, 54 | }), 55 | ); 56 | setVersion('2019'); 57 | await expect(repository.add('', '')) 58 | .resolves 59 | .not 60 | .toThrow(); 61 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalled(); 62 | }); 63 | 64 | it('fails with non-zero status code', async () => { 65 | vi.mocked(TlmgrInternals.prototype.exec).mockRejectedValueOnce( 66 | new ExecError({ 67 | command: 'tlmgr', 68 | exitCode: 2, 69 | stdout: '', 70 | stderr: '', 71 | }), 72 | ); 73 | setVersion('2019'); 74 | await expect(repository.add('', '')) 75 | .rejects 76 | .toThrow('`tlmgr` exited with status 2'); 77 | }); 78 | 79 | it('fails since the `repository` action is not implemented', async () => { 80 | setVersion('2011'); 81 | await expect(repository.add('', '')).rejects.toThrow(/./v); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/tlmgr/update.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, vi } from 'vitest'; 2 | 3 | import stderrCtan from '@setup-texlive-action/fixtures/tlmgr-setup_one_remotetlpdb-ctan.stderr'; 4 | import stderrTlcontrib from '@setup-texlive-action/fixtures/tlmgr-setup_one_remotetlpdb-tlcontrib.stderr'; 5 | import { ExecError } from '@setup-texlive-action/utils'; 6 | 7 | import { update } from '#texlive/tlmgr/actions/update'; 8 | import { TlmgrError } from '#texlive/tlmgr/errors'; 9 | import { TlmgrInternals, set } from '#texlive/tlmgr/internals'; 10 | import type { Version } from '#texlive/version'; 11 | 12 | vi.unmock('#texlive/tlmgr/actions/update'); 13 | 14 | const setVersion = (version: Version) => { 15 | set(new TlmgrInternals({ TEXDIR: '', version }), true); 16 | }; 17 | 18 | it('updates packages', async () => { 19 | setVersion(LATEST_VERSION); 20 | await expect(update(['foo', 'bar', 'baz'])).resolves.not.toThrow(); 21 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith('update', [ 22 | 'foo', 23 | 'bar', 24 | 'baz', 25 | ]); 26 | }); 27 | 28 | it('updates tlmgr itself', async () => { 29 | setVersion(LATEST_VERSION); 30 | await expect(update({ self: true })).resolves.not.toThrow(); 31 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith('update', [ 32 | '--self', 33 | ]); 34 | }); 35 | 36 | it('updates tlmgr itself by updating texlive.infra', async () => { 37 | setVersion('2008'); 38 | await expect(update({ self: true })).resolves.not.toThrow(); 39 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith('update', [ 40 | 'texlive.infra', 41 | ]); 42 | }); 43 | 44 | it('updates all packages', async () => { 45 | setVersion(LATEST_VERSION); 46 | await expect(update(['foo', 'bar'], { all: true })).resolves.not.toThrow(); 47 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith('update', [ 48 | '--all', 49 | ]); 50 | }); 51 | 52 | it('updates packages with `--reinstall-forcibly-removed`', async () => { 53 | setVersion(LATEST_VERSION); 54 | await expect( 55 | update(['foo', 'bar', 'baz'], { reinstallForciblyRemoved: true }), 56 | ) 57 | .resolves 58 | .not 59 | .toThrow(); 60 | expect(TlmgrInternals.prototype.exec).toHaveBeenCalledWith('update', [ 61 | '--reinstall-forcibly-removed', 62 | 'foo', 63 | 'bar', 64 | 'baz', 65 | ]); 66 | }); 67 | 68 | it('throws TLVersionOutdated', async () => { 69 | setVersion('2022'); 70 | vi.mocked(TlmgrInternals.prototype.exec).mockRejectedValueOnce( 71 | new ExecError({ 72 | command: 'tlmgr', 73 | stderr: stderrCtan, 74 | stdout: '', 75 | exitCode: 1, 76 | }), 77 | ); 78 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 79 | await expect(update()).rejects.toThrow(expect.objectContaining({ 80 | code: TlmgrError.Code.TL_VERSION_OUTDATED, 81 | })); 82 | }); 83 | 84 | it('throws TLVersionNotSupported', async () => { 85 | setVersion('2022'); 86 | vi.mocked(TlmgrInternals.prototype.exec).mockRejectedValueOnce( 87 | new ExecError({ 88 | command: 'tlmgr', 89 | stderr: stderrTlcontrib, 90 | stdout: '', 91 | exitCode: 1, 92 | }), 93 | ); 94 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 95 | await expect(update()).rejects.toThrow(expect.objectContaining({ 96 | code: TlmgrError.Code.TL_VERSION_NOT_SUPPORTED, 97 | })); 98 | }); 99 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/tlpdb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import tlpdb2008 from '@setup-texlive-action/fixtures/texlive.2008.tlpdb'; 4 | import tlpdb2023 from '@setup-texlive-action/fixtures/texlive.2023.tlpdb'; 5 | 6 | import { parse } from '#texlive/tlpkg/tlpdb'; 7 | 8 | const getLocation = (db: string): string | undefined => { 9 | for (const [tag, options] of parse(db)) { 10 | if (tag === 'TLOptions') { 11 | return options.location; 12 | } 13 | } 14 | return undefined; 15 | }; 16 | 17 | const getVersion = (db: string): string | undefined => { 18 | for (const [tag, config] of parse(db)) { 19 | if (tag === 'TLConfig') { 20 | return config.release; 21 | } 22 | } 23 | return undefined; 24 | }; 25 | 26 | describe('2008', () => { 27 | test('location', () => { 28 | expect(getLocation(tlpdb2008)).toMatchInlineSnapshot( 29 | `"http://ftp.math.utah.edu/pub/tex/historic/systems/texlive/2008/tlnet"`, 30 | ); 31 | }); 32 | 33 | test('version', () => { 34 | expect(getVersion(tlpdb2008)).toBe('2008'); 35 | }); 36 | }); 37 | 38 | describe('2023', () => { 39 | test('location', () => { 40 | expect(getLocation(tlpdb2023)).toMatchInlineSnapshot( 41 | `"http://ftp.dante.de/tex-archive/systems/texlive/tlnet"`, 42 | ); 43 | }); 44 | 45 | test('version', () => { 46 | expect(getVersion(tlpdb2023)).toBe('2023'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/texlive/__tests__/tlpkg.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import * as fs from 'node:fs/promises'; 4 | import * as os from 'node:os'; 5 | 6 | import stderr from '@setup-texlive-action/fixtures/tlpkg-check_file_and_remove.stderr'; 7 | 8 | import * as tlpkg from '#texlive/tlpkg'; 9 | import type { Version } from '#texlive/version'; 10 | 11 | vi.unmock('#texlive/tlpkg/patch'); 12 | 13 | describe('check', () => { 14 | it('detects forcible removal of packages', async () => { 15 | const output = { exitCode: 0, stderr, stdout: '' }; 16 | const result = (async () => { 17 | tlpkg.TlpdbError.checkPackageChecksumMismatch(output); 18 | })(); 19 | await expect(result).rejects.toThrowErrorMatchingInlineSnapshot( 20 | `[TlpdbError: Checksums of some packages did not match]`, 21 | ); 22 | await expect(result).rejects.toMatchObject({ 23 | packages: ['babel'], 24 | }); 25 | }); 26 | }); 27 | 28 | describe('patch', () => { 29 | const directory = ''; 30 | 31 | it.each<[NodeJS.Platform, Version]>([ 32 | ['linux', `2009`], 33 | ['linux', `2010`], 34 | ['win32', `2009`], 35 | ['win32', `2010`], 36 | ])( 37 | 'applies a patch for tlpkg/TeXLive/TLWinGoo.pm on (%s %s)', 38 | async (platform, version) => { 39 | vi.mocked(os.platform).mockReturnValue(platform); 40 | await expect(tlpkg.patch({ directory, version })).resolves.not.toThrow(); 41 | expect(fs.readFile).toHaveBeenCalledWith( 42 | expect.stringContaining('TLWinGoo.pm'), 43 | 'utf8', 44 | ); 45 | }, 46 | ); 47 | 48 | it('applies a patch for tlpkg/tlperl/lib/Encode/Alias.pm', async () => { 49 | vi.mocked(os.platform).mockReturnValue('win32'); 50 | await expect(tlpkg.patch({ directory, version: `2015` })) 51 | .resolves 52 | .not 53 | .toThrow(); 54 | expect(fs.readFile).toHaveBeenCalledWith( 55 | expect.stringContaining('Alias.pm'), 56 | 'utf8', 57 | ); 58 | }); 59 | 60 | it.each<[NodeJS.Platform, Version]>([ 61 | ['win32', `2008`], 62 | ['win32', `2011`], 63 | ['win32', `2014`], 64 | ['win32', `2017`], 65 | ['darwin', `2017`], 66 | ['darwin', `2018`], 67 | ['darwin', `2019`], 68 | ])( 69 | 'applies a patch tlpkg/TeXLive/TLUtils.pm (%s %s)', 70 | async (platform, version) => { 71 | vi.mocked(os.platform).mockReturnValue(platform); 72 | await expect(tlpkg.patch({ directory, version })).resolves.not.toThrow(); 73 | expect(fs.readFile).toHaveBeenCalledWith( 74 | expect.stringContaining('TLUtils.pm'), 75 | 'utf8', 76 | ); 77 | }, 78 | ); 79 | }); 80 | -------------------------------------------------------------------------------- /packages/texlive/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setup-texlive-action/texlive", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "test": "vitest" 7 | }, 8 | "main": "src/index.ts", 9 | "type": "module", 10 | "sideEffects": false, 11 | "dependencies": { 12 | "@actions/core": "^1.11.0", 13 | "@actions/http-client": "^2.2.3", 14 | "@actions/tool-cache": "^2.0.2", 15 | "@teppeis/multimaps": "^3.0.0", 16 | "class-transformer": "^0.5.1", 17 | "deline": "^1.0.4", 18 | "scule": "^1.3.0", 19 | "ts-mixer": "^6.0.4", 20 | "ts-pattern": "^5.7.1", 21 | "unctx": "^2.4.1", 22 | "url-template": "^3.1.1" 23 | }, 24 | "devDependencies": { 25 | "@setup-texlive-action/data": "*", 26 | "@setup-texlive-action/logger": "*", 27 | "@setup-texlive-action/polyfill": "*", 28 | "@setup-texlive-action/utils": "*", 29 | "@types/deline": "^1.0.4", 30 | "nock": "^14.0.3", 31 | "texlive-json-schemas": "^0.2.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/texlive/src/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | vi.mock('#texlive/releases'); 4 | vi.mock('#texlive/tlnet'); 5 | vi.mock('#texlive/install-tl/cli'); 6 | vi.mock('#texlive/tlmgr/actions/install'); 7 | vi.mock('#texlive/tlmgr/actions/path'); 8 | vi.mock('#texlive/tlmgr/actions/pinning'); 9 | vi.mock('#texlive/tlmgr/actions/repository'); 10 | vi.mock('#texlive/tlmgr/actions/update'); 11 | vi.mock('#texlive/tlmgr/actions/internals'); 12 | 13 | export * from '../index.js'; 14 | -------------------------------------------------------------------------------- /packages/texlive/src/__mocks__/releases.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export namespace ReleaseData { 4 | const latestVersionNumber = Number.parseInt(LATEST_VERSION, 10); 5 | const data = { 6 | newVersionReleased: vi.fn().mockReturnValue(false), 7 | previous: { version: `${latestVersionNumber - 1}` }, 8 | latest: { version: LATEST_VERSION }, 9 | next: { version: `${latestVersionNumber + 1}` }, 10 | }; 11 | export const setup = vi.fn().mockResolvedValue(data); 12 | export const use = vi.fn().mockReturnValue(data); 13 | } 14 | -------------------------------------------------------------------------------- /packages/texlive/src/__mocks__/tlnet.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const ctan = vi.fn().mockResolvedValue(new URL(MOCK_URL)); 4 | export const contrib = vi.fn().mockResolvedValue(new URL(MOCK_URL)); 5 | export const historic = vi.fn().mockResolvedValue(new URL(MOCK_URL)); 6 | export const checkVersionFile = vi.fn(); 7 | -------------------------------------------------------------------------------- /packages/texlive/src/ctan/__mocks__/mirrors.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const resolve = vi.fn().mockResolvedValue(new URL(MOCK_URL)); 4 | -------------------------------------------------------------------------------- /packages/texlive/src/ctan/api.ts: -------------------------------------------------------------------------------- 1 | import { getJson } from '@setup-texlive-action/utils/http'; 2 | import { parseTemplate } from 'url-template'; 3 | 4 | const API_BASE_URL: Readonly = new URL( 5 | parseTemplate('https://ctan.org/json/{version}/pkg/{?drop}').expand({ 6 | version: '2.0', 7 | drop: [ 8 | 'aliases', 9 | 'announce', 10 | 'bugs', 11 | 'ctan', 12 | 'descriptions', 13 | 'development', 14 | 'documentation', 15 | 'home', 16 | 'index', 17 | 'install', 18 | 'repository', 19 | 'support', 20 | 'topics', 21 | ], 22 | }), 23 | ); 24 | 25 | export interface Pkg { 26 | version?: { 27 | number?: string; 28 | }; 29 | texlive?: string; 30 | } 31 | 32 | export async function pkg(name: string): Promise { 33 | const url = new URL(name, API_BASE_URL); 34 | url.search = API_BASE_URL.search; 35 | return await getJson(url); 36 | } 37 | -------------------------------------------------------------------------------- /packages/texlive/src/ctan/index.ts: -------------------------------------------------------------------------------- 1 | export * as api from '#texlive/ctan/api'; 2 | export * as mirrors from '#texlive/ctan/mirrors'; 3 | -------------------------------------------------------------------------------- /packages/texlive/src/ctan/mirrors.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from 'node:timers/promises'; 2 | 3 | import data from '@setup-texlive-action/data/tlnet.json' with { type: 'json' }; 4 | import * as log from '@setup-texlive-action/logger'; 5 | import { 6 | HttpClient, 7 | HttpCodes, 8 | createClientError, 9 | } from '@setup-texlive-action/utils/http'; 10 | 11 | const MAX_TRIES = 10; 12 | const RETRY_DELAY = 500; 13 | 14 | let resolvedMirrorLocation: Readonly | undefined; 15 | 16 | export interface CtanMirrorOptions { 17 | /** @defaultValue `false` */ 18 | readonly master?: boolean | undefined; 19 | } 20 | 21 | export async function resolve(options?: CtanMirrorOptions): Promise { 22 | if (options?.master ?? false) { 23 | return new URL(data.ctan.master); 24 | } 25 | if (resolvedMirrorLocation !== undefined) { 26 | return new URL(resolvedMirrorLocation.href); 27 | } 28 | using http = new HttpClient(undefined, undefined, { 29 | allowRedirects: false, 30 | keepAlive: true, 31 | }); 32 | for (let i = 0; i < MAX_TRIES; ++i) { 33 | try { 34 | const { message } = await http.head(data.ctan.mirrors); 35 | const { headers, statusCode = Number.NaN } = message.destroy(); 36 | if (!REDIRECT_CODES.has(statusCode as HttpCodes)) { 37 | throw createClientError(statusCode, data.ctan.mirrors); 38 | } 39 | const mirror = new URL(headers.location!); 40 | log.debug( 41 | '[%d/%d] Resolved CTAN mirror: %s', 42 | i + 1, 43 | MAX_TRIES, 44 | mirror.href, 45 | ); 46 | // These mirrors are quite unstable and 47 | // often cause problems with package checksum mismatches. 48 | if (/cicku/iv.test(mirror.hostname)) { 49 | await setTimeout(RETRY_DELAY); 50 | continue; 51 | } 52 | resolvedMirrorLocation = mirror; 53 | return new URL(mirror.href); 54 | } catch (cause) { 55 | throw new Error('Failed to resolve the CTAN mirror location', { cause }); 56 | } 57 | } 58 | throw new Error('Failed to find a suitable CTAN mirror'); 59 | } 60 | 61 | const REDIRECT_CODES: ReadonlySet = new Set([ 62 | HttpCodes.MovedPermanently, 63 | HttpCodes.ResourceMoved, 64 | HttpCodes.SeeOther, 65 | HttpCodes.TemporaryRedirect, 66 | HttpCodes.PermanentRedirect, 67 | ]); 68 | 69 | /* eslint no-await-in-loop: off */ 70 | -------------------------------------------------------------------------------- /packages/texlive/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from '@setup-texlive-action/utils'; 2 | 3 | import type { Version } from '#texlive/version'; 4 | 5 | export interface TLErrorOptions extends ErrorOptions { 6 | code?: string; 7 | version?: Version | undefined; 8 | repository?: Readonly | string | undefined; 9 | remoteVersion?: string | undefined; 10 | } 11 | 12 | @Exception 13 | export abstract class TLError extends Error implements TLErrorOptions { 14 | declare code?: string; 15 | declare version?: Version; 16 | declare repository?: string; 17 | declare remoteVersion?: string; 18 | 19 | constructor(msg: string, options?: Readonly) { 20 | super(msg, options); 21 | if (options?.code !== undefined) { 22 | this.code = options.code; 23 | } 24 | if (options?.version !== undefined) { 25 | this.version = options.version; 26 | } 27 | if (options?.repository !== undefined) { 28 | this.repository = options.repository.toString(); 29 | } 30 | if (options?.remoteVersion !== undefined) { 31 | this.remoteVersion = options.remoteVersion; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/texlive/src/index.ts: -------------------------------------------------------------------------------- 1 | export * as ctan from '#texlive/ctan'; 2 | export * from '#texlive/install-tl'; 3 | export * from '#texlive/releases'; 4 | export * from '#texlive/tex'; 5 | export * from '#texlive/tlmgr'; 6 | export * as tlnet from '#texlive/tlnet'; 7 | export * as tlpkg from '#texlive/tlpkg'; 8 | export { TlpdbError } from '#texlive/tlpkg'; 9 | export * from '#texlive/version'; 10 | -------------------------------------------------------------------------------- /packages/texlive/src/install-tl/__mocks__/profile.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const { Profile } = await vi.importActual< 4 | typeof import('#texlive/install-tl/profile') 5 | >('#texlive/install-tl/profile'); 6 | 7 | vi.spyOn(Profile.prototype, 'open').mockResolvedValue('texlive.profile'); 8 | -------------------------------------------------------------------------------- /packages/texlive/src/install-tl/errors.ts: -------------------------------------------------------------------------------- 1 | import { symbols } from '@setup-texlive-action/logger'; 2 | import { Exception, type ExecOutput } from '@setup-texlive-action/utils'; 3 | import deline from 'deline'; 4 | 5 | import { TLError, type TLErrorOptions } from '#texlive/errors'; 6 | 7 | @Exception 8 | export class InstallTLError extends TLError { 9 | declare readonly code?: InstallTLError.Code; 10 | } 11 | 12 | export namespace InstallTLError { 13 | const CODES = [ 14 | 'INCOMPATIBLE_REPOSITORY_VERSION', 15 | 'UNEXPECTED_VERSION', 16 | 'FAILED_TO_DOWNLOAD', 17 | ] as const; 18 | 19 | export type Code = typeof CODES[number]; 20 | 21 | export const Code = Object.fromEntries( 22 | CODES.map((code) => [code, code]), 23 | ) as { 24 | readonly [K in Code]: K; 25 | }; 26 | } 27 | 28 | export namespace InstallTLError { 29 | const MSG = 'repository being accessed are not compatible'; 30 | // eslint-disable-next-line regexp/no-super-linear-move 31 | const RE = /^\s*repository:\s*(?20\d{2})/mv; 32 | 33 | export function checkCompatibility( 34 | output: Readonly, 35 | options?: Readonly, 36 | ): void { 37 | if (output.exitCode !== 0 && output.stderr.includes(MSG)) { 38 | const remoteVersion = RE.exec(output.stderr)?.groups?.['remote']; 39 | const error = new InstallTLError( 40 | 'The repository is not compatible with this version of install-tl', 41 | { 42 | ...options, 43 | code: InstallTLError.Code.INCOMPATIBLE_REPOSITORY_VERSION, 44 | remoteVersion, 45 | }, 46 | ); 47 | error[symbols.note] = deline` 48 | The CTAN mirrors may not have completed synchronisation 49 | against a release of new version of TeX Live. 50 | Please try re-running the workflow after a while. 51 | `; 52 | throw error; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/texlive/src/install-tl/index.ts: -------------------------------------------------------------------------------- 1 | export * from '#texlive/install-tl/cli'; 2 | export * from '#texlive/install-tl/errors'; 3 | export * from '#texlive/install-tl/profile'; 4 | -------------------------------------------------------------------------------- /packages/texlive/src/install-tl/texmf.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { AsPath, FromEnv } from '@setup-texlive-action/utils'; 4 | import { Exclude, Expose, instanceToPlain } from 'class-transformer'; 5 | import { decorate as Decorate } from 'ts-mixer'; 6 | 7 | import { Texmf } from '#texlive/tex/texmf'; 8 | import type { Version } from '#texlive/version'; 9 | 10 | export type TexmfOptions = 11 | & ( 12 | | { readonly prefix: string; readonly texdir?: undefined } 13 | | { readonly prefix?: string | undefined; readonly texdir: string } 14 | ) 15 | & { readonly texuserdir?: string | undefined }; 16 | 17 | @Exclude() 18 | export class SystemTrees implements Texmf.SystemTrees { 19 | @Decorate(Expose()) 20 | declare readonly TEXDIR: string; 21 | 22 | @Decorate(Expose()) 23 | @FromEnv('TEXLIVE_INSTALL_TEXMFLOCAL') 24 | @AsPath 25 | declare readonly TEXMFLOCAL: string; 26 | 27 | @Decorate(Expose()) 28 | @FromEnv('TEXLIVE_INSTALL_TEXMFSYSCONFIG') 29 | @AsPath 30 | declare readonly TEXMFSYSCONFIG: string; 31 | 32 | @Decorate(Expose()) 33 | @FromEnv('TEXLIVE_INSTALL_TEXMFSYSVAR') 34 | @AsPath 35 | declare readonly TEXMFSYSVAR: string; 36 | 37 | constructor(readonly version: Version, options: TexmfOptions) { 38 | if (options.texdir === undefined) { 39 | this.#withPrefix(options.prefix); 40 | } 41 | Object.assign(this, instanceToPlain(this)); 42 | if (options.texdir !== undefined) { 43 | this.#withTexdir(options.texdir); 44 | } 45 | } 46 | 47 | #withPrefix(this: Writable, prefix: string): void { 48 | this.TEXMFLOCAL = path.join(prefix, 'texmf-local'); 49 | (this as this).#withTexdir(path.join(prefix, this.version)); 50 | } 51 | 52 | #withTexdir(this: Writable>, texdir: string): void { 53 | this.TEXDIR = texdir; 54 | this.TEXMFSYSCONFIG = path.join(texdir, 'texmf-config'); 55 | this.TEXMFSYSVAR = path.join(texdir, 'texmf-var'); 56 | this.TEXMFLOCAL ??= path.join(texdir, 'texmf-local'); 57 | } 58 | 59 | get TEXMFROOT(): string { 60 | return this.TEXDIR; 61 | } 62 | } 63 | 64 | @Exclude() 65 | export class UserTrees implements Texmf.UserTrees { 66 | @Decorate(Expose()) 67 | @FromEnv('TEXLIVE_INSTALL_TEXMFHOME') 68 | @AsPath 69 | declare readonly TEXMFHOME: string; 70 | 71 | @Decorate(Expose()) 72 | @FromEnv('TEXLIVE_INSTALL_TEXMFCONFIG') 73 | @AsPath 74 | declare readonly TEXMFCONFIG: string; 75 | 76 | @Decorate(Expose()) 77 | @FromEnv('TEXLIVE_INSTALL_TEXMFVAR') 78 | @AsPath 79 | declare readonly TEXMFVAR: string; 80 | 81 | constructor(readonly version: Version, options: TexmfOptions) { 82 | if (options.texuserdir !== undefined) { 83 | this.#withTexuserdir(options.texuserdir); 84 | } else { 85 | this.#withSystemTrees(options); 86 | Object.assign(this, instanceToPlain(this)); 87 | } 88 | } 89 | 90 | #withTexuserdir(this: Writable, texuserdir: string): void { 91 | this.TEXMFHOME = path.join(texuserdir, 'texmf'); 92 | this.TEXMFCONFIG = path.join(texuserdir, 'texmf-config'); 93 | this.TEXMFVAR = path.join(texuserdir, 'texmf-var'); 94 | } 95 | 96 | #withSystemTrees(this: Writable, options: TexmfOptions): void { 97 | const trees = new SystemTrees(this.version, options); 98 | this.TEXMFHOME = trees.TEXMFLOCAL; 99 | this.TEXMFCONFIG = trees.TEXMFSYSCONFIG; 100 | this.TEXMFVAR = trees.TEXMFSYSVAR; 101 | } 102 | } 103 | 104 | /* eslint @typescript-eslint/prefer-readonly-parameter-types: off */ 105 | -------------------------------------------------------------------------------- /packages/texlive/src/tex/__mocks__/kpse.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const varValue = vi.fn(async (key) => `<${key}>`); 4 | -------------------------------------------------------------------------------- /packages/texlive/src/tex/index.ts: -------------------------------------------------------------------------------- 1 | export * as kpse from '#texlive/tex/kpse'; 2 | export * from '#texlive/tex/texmf'; 3 | -------------------------------------------------------------------------------- /packages/texlive/src/tex/kpse.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { exec } from '@setup-texlive-action/utils'; 4 | 5 | import type { Texmf } from '#texlive/tex/texmf'; 6 | 7 | export async function varValue( 8 | variable: Exclude, 9 | ): Promise { 10 | const { exitCode, stdout } = await exec('kpsewhich', [ 11 | `-var-value=${variable}`, 12 | ], { 13 | ignoreReturnCode: true, 14 | silent: true, 15 | }); 16 | return exitCode === 0 17 | ? path.normalize(stdout.replace(/\r?\n$/v, '')) 18 | : undefined; 19 | } 20 | -------------------------------------------------------------------------------- /packages/texlive/src/tex/texmf.ts: -------------------------------------------------------------------------------- 1 | export namespace Texmf { 2 | export const SYSTEM_TREES = [ 3 | 'TEXDIR', 4 | 'TEXMFLOCAL', 5 | 'TEXMFSYSCONFIG', 6 | 'TEXMFSYSVAR', 7 | ] as const; 8 | 9 | export const USER_TREES = [ 10 | 'TEXMFHOME', 11 | 'TEXMFCONFIG', 12 | 'TEXMFVAR', 13 | ] as const; 14 | 15 | export type SystemTrees = { 16 | readonly [Key in typeof SYSTEM_TREES[number]]: string; 17 | }; 18 | 19 | export type UserTrees = { 20 | readonly [Key in typeof USER_TREES[number]]: string; 21 | }; 22 | } 23 | 24 | export interface Texmf extends Texmf.SystemTrees, Texmf.UserTrees {} 25 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/__mocks__/internals.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const { 4 | TlmgrInternals, 5 | set, 6 | use, 7 | } = await vi.importActual( 8 | '#texlive/tlmgr/internals', 9 | ); 10 | 11 | vi.spyOn(TlmgrInternals.prototype, 'exec'); 12 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/action.ts: -------------------------------------------------------------------------------- 1 | import { Range } from 'semver'; 2 | 3 | export type TlmgrAction = 4 | | 'conf' 5 | | 'install' 6 | | 'list' 7 | // | 'option' 8 | | 'path' 9 | | 'pinning' 10 | | 'repository' 11 | | 'update' 12 | | 'version'; 13 | 14 | export const SUPPORTED_VERSIONS = { 15 | conf: new Range('>=2010'), 16 | install: new Range('*'), 17 | // list: new Range('*'), 18 | // option: new Range('*'), 19 | pinning: new Range('>=2013'), 20 | repository: new Range('>=2012'), 21 | update: new Range('*'), 22 | version: new Range('*'), 23 | } as const satisfies Partial>; 24 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/__mocks__/conf.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | const actual = await vi.importActual< 4 | typeof import('#texlive/tlmgr/actions/conf') 5 | >('#texlive/tlmgr/actions/conf'); 6 | 7 | vi.spyOn(actual, 'texmf'); 8 | 9 | export const { texmf } = actual; 10 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/__mocks__/list.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const list = vi.fn(function*() {}); 4 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/__mocks__/update.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | const actual = await vi.importActual< 4 | typeof import('#texlive/tlmgr/actions/update') 5 | >('#texlive/tlmgr/actions/update'); 6 | 7 | vi.spyOn(actual, 'update'); 8 | 9 | export const { update } = actual; 10 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/conf.ts: -------------------------------------------------------------------------------- 1 | import { exportVariable } from '@actions/core'; 2 | import * as log from '@setup-texlive-action/logger'; 3 | import { exec } from '@setup-texlive-action/utils'; 4 | 5 | import { type Texmf, kpse } from '#texlive/tex'; 6 | import { use } from '#texlive/tlmgr/internals'; 7 | import * as tlpkg from '#texlive/tlpkg'; 8 | 9 | export type KpseVar = Exclude< 10 | keyof Texmf, 11 | 'TEXDIR' | 'TEXMFSYSCONFIG' | 'TEXMFSYSVAR' 12 | >; 13 | 14 | export function texmf(key: KpseVar): Promise; 15 | export function texmf(key: KpseVar, value: string): Promise; 16 | 17 | export async function texmf( 18 | key: KpseVar, 19 | value?: string, 20 | ): Promise { 21 | if (value === undefined) { 22 | return await kpse.varValue(key); 23 | } 24 | const internals = use(); 25 | // `tlmgr conf` is not implemented prior to 2010. 26 | if (internals.version < '2010') { 27 | exportVariable(key, value); 28 | } else { 29 | await internals.exec('conf', ['texmf', key, value]); 30 | } 31 | if (key === 'TEXMFLOCAL') { 32 | try { 33 | // Minimal initialisation. 34 | await tlpkg.makeLocalSkeleton(value, internals); 35 | await exec('mktexlsr', [value]); 36 | } catch (error) { 37 | log.info({ error }, 'Failed to initialize %s', key); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * as conf from '#texlive/tlmgr/actions/conf'; 2 | export * from '#texlive/tlmgr/actions/install'; 3 | export * from '#texlive/tlmgr/actions/list'; 4 | export * as path from '#texlive/tlmgr/actions/path'; 5 | export * as pinning from '#texlive/tlmgr/actions/pinning'; 6 | export * as repository from '#texlive/tlmgr/actions/repository'; 7 | export * from '#texlive/tlmgr/actions/update'; 8 | export * from '#texlive/tlmgr/actions/version'; 9 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/list.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises'; 2 | import * as path from 'node:path'; 3 | 4 | import * as log from '@setup-texlive-action/logger'; 5 | import { P, match } from 'ts-pattern'; 6 | 7 | import { use } from '#texlive/tlmgr/internals'; 8 | import { type TLPObj, tlpdb } from '#texlive/tlpkg'; 9 | 10 | /** 11 | * Lists packages by reading `texlive.tlpdb` directly 12 | * instead of running `tlmgr list`. 13 | */ 14 | export async function* list(): AsyncGenerator { 15 | const tlpdbPath = path.join(use().TEXDIR, 'tlpkg', 'texlive.tlpdb'); 16 | let db: string; 17 | try { 18 | db = await readFile(tlpdbPath, 'utf8'); 19 | } catch (error) { 20 | log.info({ error }, 'Failed to read %s', tlpdbPath); 21 | return; 22 | } 23 | try { 24 | for (const [tag, data] of tlpdb.parse(db)) { 25 | if ( 26 | tag === 'TLPOBJ' && match(data.name) 27 | .with('texlive.infra', () => true) 28 | .with(P.string.includes('.'), () => false) // platform-specific subpackage 29 | .with(P.string.startsWith('scheme-'), () => false) 30 | .with(P.string.startsWith('collection-'), () => false) 31 | .otherwise(() => true) 32 | ) { 33 | yield data; 34 | } 35 | } 36 | } catch (error) { 37 | log.info({ error }, 'Failed to parse %s', tlpdbPath); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/path.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { addPath } from '@actions/core'; 4 | import { uniqueChild } from '@setup-texlive-action/utils'; 5 | 6 | import { use } from '#texlive/tlmgr/internals'; 7 | 8 | export async function add(): Promise { 9 | let dir: string; 10 | try { 11 | dir = await uniqueChild(path.join(use().TEXDIR, 'bin')); 12 | } catch (cause) { 13 | throw new Error("Unable to locate TeX Live's binary directory", { cause }); 14 | } 15 | addPath(dir); 16 | } 17 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/pinning.ts: -------------------------------------------------------------------------------- 1 | import { use } from '#texlive/tlmgr/internals'; 2 | 3 | export async function add( 4 | repo: string, 5 | ...globs: readonly [string, ...string[]] 6 | ): Promise { 7 | await use().exec('pinning', ['add', repo, ...globs]); 8 | } 9 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/repository.ts: -------------------------------------------------------------------------------- 1 | import { ExecError } from '@setup-texlive-action/utils'; 2 | 3 | import { use } from '#texlive/tlmgr/internals'; 4 | 5 | export interface RepositoryConfig { 6 | readonly path: string; 7 | readonly tag: string | undefined; 8 | } 9 | 10 | export async function add( 11 | repo: string | Readonly, 12 | tag?: string, 13 | ): Promise { 14 | const args = ['add', repo.toString()]; 15 | if (tag !== undefined) { 16 | args.push(tag); 17 | } 18 | try { 19 | await use().exec('repository', args); 20 | } catch (error) { 21 | // `tlmgr repository add` returns non-zero status code 22 | // if the same repository or tag is added again. 23 | // (todo: make sure that the tagged repo is really tlcontrib) 24 | if ( 25 | !( 26 | error instanceof ExecError 27 | && error.stderr.includes('repository or its tag already defined') 28 | ) 29 | ) { 30 | throw error; 31 | } 32 | } 33 | } 34 | 35 | export async function remove(repo: string | Readonly): Promise { 36 | await use().exec('repository', ['remove', repo.toString()]); 37 | } 38 | 39 | export async function* list(): AsyncGenerator { 40 | const { stdout } = await use().exec('repository', ['list']); 41 | const re = /^\t(?.+) \((?.+)\)$/v; 42 | for (const line of stdout.split(/\r?\n/v).slice(1)) { 43 | const found = re.exec(line)?.groups ?? {}; 44 | yield { 45 | path: found['path'] ?? line.trim(), 46 | tag: found['tag'], 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/update.ts: -------------------------------------------------------------------------------- 1 | import { ExecError, ExecResult, isIterable } from '@setup-texlive-action/utils'; 2 | import { P, match } from 'ts-pattern'; 3 | 4 | import { TlmgrError } from '#texlive/tlmgr/errors'; 5 | import { use } from '#texlive/tlmgr/internals'; 6 | import { TlpdbError } from '#texlive/tlpkg'; 7 | 8 | export interface UpdateOptions { 9 | readonly all?: boolean; 10 | readonly self?: boolean; 11 | readonly reinstallForciblyRemoved?: boolean; 12 | } 13 | 14 | export function update(): Promise; 15 | export function update(options: UpdateOptions): Promise; 16 | export function update(packages: Iterable): Promise; 17 | export function update( 18 | packages: Iterable, 19 | options: UpdateOptions, 20 | ): Promise; 21 | export async function update( 22 | ...inputs: 23 | | readonly [] 24 | | readonly [UpdateOptions] 25 | | readonly [Iterable] 26 | | readonly [Iterable, UpdateOptions] 27 | ): Promise { 28 | const internals = use(); 29 | const [packages, options] = match(inputs) 30 | .returnType<[Iterable, UpdateOptions | undefined]>() 31 | .with( 32 | [P._, P._], 33 | [P.when(isIterable)], 34 | ([packages, options]) => [packages, options], 35 | ) 36 | .with(P._, ([options]) => [[], options]) 37 | .exhaustive(); 38 | 39 | const { 40 | all = false, 41 | reinstallForciblyRemoved = false, 42 | self = false, 43 | } = options ?? {}; 44 | 45 | const args = all ? ['--all'] : [...packages]; 46 | 47 | if (self) { 48 | // tlmgr for TeX Live 2008 does not have `self` option 49 | args.push(internals.version > '2008' ? '--self' : 'texlive.infra'); 50 | } 51 | 52 | // `--reinstall-forcibly-removed` was first implemented in TeX Live 2009. 53 | if (reinstallForciblyRemoved && internals.version >= '2009') { 54 | args.unshift('--reinstall-forcibly-removed'); 55 | } 56 | 57 | const action = 'update'; 58 | 59 | try { 60 | return await internals.exec(action, [...args]); 61 | } catch (cause) { 62 | if (cause instanceof ExecError) { 63 | const opts = { action, cause, version: internals.version } as const; 64 | TlpdbError.checkRepositoryStatus(cause, opts); 65 | TlpdbError.checkRepositoryHealth(cause, opts); 66 | TlmgrError.checkOutdated(cause, opts); 67 | TlmgrError.checkNotSupported(cause, opts); 68 | } 69 | throw cause; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/actions/version.ts: -------------------------------------------------------------------------------- 1 | import { use } from '#texlive/tlmgr/internals'; 2 | 3 | export async function version(): Promise { 4 | await use().exec('version', undefined, { ignoreReturnCode: true }); 5 | } 6 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/index.ts: -------------------------------------------------------------------------------- 1 | import type { DeepReadonly } from 'ts-essentials'; 2 | 3 | import * as tlmgr from '#texlive/tlmgr/actions'; 4 | import { 5 | type TlmgrConfig, 6 | TlmgrInternals, 7 | set, 8 | } from '#texlive/tlmgr/internals'; 9 | 10 | export type Tlmgr = DeepReadonly; 11 | 12 | export namespace Tlmgr { 13 | export function setup(config: TlmgrConfig): Tlmgr { 14 | set(new TlmgrInternals(config)); 15 | return use(); 16 | } 17 | 18 | export function use(): Tlmgr { 19 | return tlmgr; 20 | } 21 | } 22 | 23 | export type { TlmgrAction } from '#texlive/tlmgr/action'; 24 | export type { RepositoryConfig } from '#texlive/tlmgr/actions/repository'; 25 | export type { UpdateOptions } from '#texlive/tlmgr/actions/update'; 26 | export * from '#texlive/tlmgr/errors'; 27 | export type { TlmgrConfig }; 28 | // @internal 29 | export { tlmgr }; 30 | -------------------------------------------------------------------------------- /packages/texlive/src/tlmgr/internals.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ExecOptions, 3 | type ExecResult, 4 | exec, 5 | } from '@setup-texlive-action/utils'; 6 | import { createContext } from 'unctx'; 7 | 8 | import { SUPPORTED_VERSIONS } from '#texlive/tlmgr/action'; 9 | import { TlmgrError } from '#texlive/tlmgr/errors'; 10 | import { Version } from '#texlive/version'; 11 | 12 | export interface TlmgrConfig { 13 | readonly TEXDIR: string; 14 | readonly version: Version; 15 | } 16 | 17 | export class TlmgrInternals implements TlmgrConfig { 18 | readonly TEXDIR: string; 19 | readonly version: Version; 20 | 21 | constructor(config: TlmgrConfig) { 22 | this.TEXDIR = config.TEXDIR; 23 | this.version = config.version; 24 | } 25 | 26 | async exec( 27 | action: keyof typeof SUPPORTED_VERSIONS, 28 | args?: Iterable, 29 | /* eslint-disable-next-line 30 | @typescript-eslint/prefer-readonly-parameter-types */ 31 | options?: ExecOptions, 32 | ): Promise { 33 | if (!Version.satisfies(this.version, SUPPORTED_VERSIONS[action])) { 34 | throw new TlmgrError( 35 | `\`tlmgr ${action}\` not implemented in this version of TeX Live`, 36 | { action, version: this.version }, 37 | ); 38 | } 39 | return await exec('tlmgr', [action, ...(args ?? [])], options); 40 | } 41 | } 42 | 43 | export const { set, use } = createContext(); 44 | -------------------------------------------------------------------------------- /packages/texlive/src/tlnet.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingHttpHeaders } from 'node:http'; 2 | 3 | import { match } from '@setup-texlive-action/data'; 4 | import tlnet from '@setup-texlive-action/data/tlnet.json' with { type: 'json' }; 5 | import { getHeaders } from '@setup-texlive-action/utils/http'; 6 | import { parseTemplate } from 'url-template'; 7 | 8 | import { mirrors } from '#texlive/ctan'; 9 | import { Version } from '#texlive/version'; 10 | 11 | export type TlnetOptions = mirrors.CtanMirrorOptions; 12 | 13 | export async function ctan(options?: TlnetOptions): Promise { 14 | return new URL(tlnet.ctan.path, await mirrors.resolve(options)); 15 | } 16 | 17 | export async function contrib(options?: TlnetOptions): Promise { 18 | return new URL(tlnet.tlcontrib.path, await mirrors.resolve(options)); 19 | } 20 | 21 | export function historic(version: Version, options?: TlnetOptions): URL { 22 | const [template] = match(tlnet.historic.path, { version }); 23 | const tlnetPath = parseTemplate(template).expand({ version }); 24 | const base = (options?.master ?? false) 25 | ? tlnet.historic.master 26 | : tlnet.historic.default; 27 | return new URL(tlnetPath, base); 28 | } 29 | 30 | export async function checkVersionFile( 31 | repository: Readonly, 32 | version: Version, 33 | ): Promise { 34 | const pretest = repository.pathname.includes(tlnet.tlpretest.path); 35 | const template = tlnet[pretest ? 'tlpretest' : 'ctan'].versionFile; 36 | const file = parseTemplate(template).expand({ version }); 37 | try { 38 | return await getHeaders(new URL(file, repository)); 39 | } catch { 40 | return undefined; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/texlive/src/tlpkg/errors.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { symbols } from '@setup-texlive-action/logger'; 4 | import { Exception, type ExecOutput } from '@setup-texlive-action/utils'; 5 | import deline from 'deline'; 6 | 7 | import { TLError, type TLErrorOptions } from '#texlive/errors'; 8 | 9 | @Exception 10 | export class TlpdbError extends TLError { 11 | declare readonly code?: TlpdbError.Code; 12 | } 13 | 14 | export namespace TlpdbError { 15 | const CODES = [ 16 | 'PACKAGE_CHECKSUM_MISMATCH', 17 | 'FAILED_TO_INITIALIZE', 18 | 'TLPDB_CHECKSUM_MISMATCH', 19 | ] as const; 20 | 21 | export type Code = typeof CODES[number]; 22 | 23 | export const Code = Object.fromEntries( 24 | CODES.map((code) => [code, code]), 25 | ) as { 26 | readonly [K in Code]: K; 27 | }; 28 | } 29 | 30 | export namespace TlpdbError { 31 | /** @see `tlpkg/TeXLive/TLUtils.pm` */ 32 | const RE = /: checksums differ for (.+):$/gmv; 33 | 34 | /** 35 | * @see {@link https://github.com/teatimeguest/setup-texlive-action/issues/226} 36 | */ 37 | export function checkPackageChecksumMismatch( 38 | output: Readonly, 39 | options?: Readonly, 40 | ): void { 41 | const packages = Array.from( 42 | output.stderr.matchAll(RE), 43 | ([, found]) => path.basename(found!, '.tar.xz'), 44 | ); 45 | if (packages.length > 0) { 46 | const error = new TlpdbError( 47 | 'Checksums of some packages did not match', 48 | { ...options, code: TlpdbError.Code.PACKAGE_CHECKSUM_MISMATCH }, 49 | ); 50 | error['packages'] = [...new Set(packages.sort())]; 51 | error[symbols.note] = deline` 52 | The CTAN mirror may be in the process of synchronisation. 53 | Please try re-running the workflow after a while. 54 | `; 55 | throw error; 56 | } 57 | } 58 | } 59 | 60 | export namespace TlpdbError { 61 | /** @see `tlpkg/TeXLive/TLPDB.pm` */ 62 | const RE = 63 | /TLPDB::from_file could not (?:initialize|get texlive\.tlpdb) from: (.*)$/mv; 64 | 65 | export function checkRepositoryStatus( 66 | output: Readonly, 67 | options?: Readonly, 68 | ): void { 69 | if (output.exitCode !== 0) { 70 | const url = RE.exec(output.stderr)?.[1]; 71 | if (url !== undefined) { 72 | const error = new TlpdbError( 73 | 'Repository initialization failed', 74 | { ...options, code: TlpdbError.Code.FAILED_TO_INITIALIZE }, 75 | ); 76 | error[symbols.note] = deline` 77 | The repository may not have been synchronized yet. 78 | Please try re-running the workflow after a while. 79 | `; 80 | error['stderr'] = output.stderr; 81 | error['url'] = url; 82 | throw error; 83 | } 84 | } 85 | } 86 | } 87 | 88 | export namespace TlpdbError { 89 | /** @see `tlpkg/TeXLive/TLPDB.pm` */ 90 | const RE = /from (.+): digest disagree/v; 91 | 92 | export function checkRepositoryHealth( 93 | output: Readonly, 94 | options?: Readonly, 95 | ): void { 96 | const url = RE.exec(output.stderr)?.[1]; 97 | if (url !== undefined) { 98 | const error = new TlpdbError( 99 | 'Repository initialization failed', 100 | { ...options, code: TlpdbError.Code.TLPDB_CHECKSUM_MISMATCH }, 101 | ); 102 | error[symbols.note] = deline` 103 | The repository seems to have some problem. 104 | Please try re-running the workflow after a while. 105 | `; 106 | error['stderr'] = output.stderr; 107 | error['url'] = url; 108 | throw error; 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/texlive/src/tlpkg/index.ts: -------------------------------------------------------------------------------- 1 | export * from '#texlive/tlpkg/errors'; 2 | export * from '#texlive/tlpkg/patch'; 3 | export * as tlpdb from '#texlive/tlpkg/tlpdb'; 4 | export type * from '#texlive/tlpkg/tlpdb'; 5 | export * from '#texlive/tlpkg/util'; 6 | -------------------------------------------------------------------------------- /packages/texlive/src/tlpkg/patch.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'node:fs/promises'; 2 | import { EOL } from 'node:os'; 3 | import * as path from 'node:path'; 4 | 5 | import { satisfies } from '@setup-texlive-action/data'; 6 | import patches from '@setup-texlive-action/data/tlpkg-patches.json' with { 7 | type: 'json', 8 | }; 9 | import * as log from '@setup-texlive-action/logger'; 10 | import { exec } from '@setup-texlive-action/utils/exec'; 11 | import type { DeepReadonly } from 'ts-essentials'; 12 | 13 | import { Version } from '#texlive/version'; 14 | 15 | export async function patch(options: { 16 | readonly directory: string; 17 | readonly version: Version; 18 | }): Promise { 19 | const ps = patches.patches.filter((p) => satisfies(p, options)); 20 | if (ps.length > 0) { 21 | log.info('Applying patches'); 22 | const lines = await Promise.all(ps.map((p) => apply(p, options.directory))); 23 | log.info({ linePrefix: log.styles.blue`|` + ' ' }, lines.flat().join(EOL)); 24 | } 25 | } 26 | 27 | type Patch = DeepReadonly; 28 | 29 | async function apply( 30 | { description, file, changes }: Patch, 31 | directory: string, 32 | ): Promise { 33 | const diff = async (modified: string): Promise => { 34 | try { 35 | const { exitCode, stdout, stderr } = await exec('git', [ 36 | 'diff', 37 | '--no-index', 38 | `--color=${log.hasColors() ? 'always' : 'never'}`, 39 | '--', 40 | file, 41 | '-', 42 | ], { 43 | stdin: modified, 44 | cwd: directory, 45 | silent: true, 46 | ignoreReturnCode: true, 47 | }); 48 | if (exitCode === 1) { 49 | return [log.styles.blue(description), stdout.trimEnd()]; 50 | } 51 | if (exitCode > 1) { 52 | log.debug('git-diff exited with %d: %s', exitCode, stderr); 53 | } 54 | } catch (error) { 55 | log.debug({ error }, 'Failed to exec git-diff'); 56 | } 57 | return []; 58 | }; 59 | 60 | const target = path.join(directory, file); 61 | let content = await readFile(target, 'utf8'); 62 | for (const { from, to } of changes) { 63 | content = content.replace(new RegExp(from, 'v'), to); 64 | } 65 | const lines = await diff(content); 66 | await writeFile(target, content); 67 | return lines; 68 | } 69 | -------------------------------------------------------------------------------- /packages/texlive/src/tlpkg/tlpdb.ts: -------------------------------------------------------------------------------- 1 | import type { TLPDBSINGLE, TLPOBJ } from 'texlive-json-schemas/types'; 2 | 3 | export interface TLPObj { 4 | name: TLPOBJ['name']; 5 | revision: string; 6 | cataloguedata?: { 7 | version?: NonNullable['version']; 8 | }; 9 | } 10 | 11 | export interface TLConfig 12 | extends Pick, 'release'> 13 | {} 14 | 15 | export interface TLOptions { 16 | location?: string; 17 | } 18 | 19 | const TAG = { 20 | TLPOBJ: 'TLPOBJ', 21 | TLConfig: 'TLConfig', 22 | TLOptions: 'TLOptions', 23 | } as const; 24 | 25 | export type Entry = 26 | | [typeof TAG.TLPOBJ, TLPObj] 27 | | [typeof TAG.TLConfig, TLConfig] 28 | | [typeof TAG.TLOptions, TLOptions]; 29 | 30 | const RE = { 31 | version: /^catalogue-version\s+(\S.*)$/mv, 32 | revision: /^revision\s+(\d+)\s*$/mv, 33 | location: /^depend\s+(?:opt_)?location:(.+)$/mv, 34 | release: /^depend\s+release\/(.+)$/mv, 35 | } as const; 36 | 37 | export function* parse(db: string): Generator { 38 | for (const [name, data] of entries(db)) { 39 | if ( 40 | name === '00texlive-installation.config' 41 | || name === '00texlive.installation' 42 | ) { 43 | if (name === '00texlive-installation.config') { 44 | yield [TAG.TLConfig, { release: '2008' }]; 45 | } 46 | const location = RE.location.exec(data)?.[1]; 47 | if (location !== undefined) { 48 | yield [TAG.TLOptions, { location }]; 49 | } 50 | } else if (name === '00texlive.config') { 51 | const release = RE.release.exec(data)?.[1]; 52 | if (release !== undefined) { 53 | yield [TAG.TLConfig, { release }]; 54 | } 55 | } else if (!name.startsWith('00texlive')) { 56 | const version = RE.version.exec(data)?.[1]?.trimEnd(); 57 | const revision = RE.revision.exec(data)?.[1] ?? ''; 58 | yield [TAG.TLPOBJ, { name, revision, cataloguedata: { version } }]; 59 | } 60 | } 61 | } 62 | 63 | function* entries( 64 | db: string, 65 | ): Generator<[name: string, data: string], void, void> { 66 | // dprint-ignore 67 | const iter = db 68 | .replaceAll(/\\\r?\n/gv, '') // Remove escaped line breaks 69 | .replaceAll(/#.*/gv, '') // Remove comments 70 | .split(/^name\s(.*)$/mv) // Split into individual packages 71 | .values(); 72 | iter.next(); // The first chunk should contain nothing. 73 | for (const name of iter) { 74 | const data = iter.next().value; 75 | yield [name.trimEnd(), data ?? '']; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/texlive/src/tlpkg/util.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { exec } from '@setup-texlive-action/utils'; 4 | 5 | /** 6 | * Initialize TEXMFLOCAL just as the installer does. 7 | */ 8 | export async function makeLocalSkeleton( 9 | texmflocal: string, 10 | options: { readonly TEXDIR: string }, 11 | ): Promise { 12 | await exec('perl', [ 13 | `-I${path.join(options.TEXDIR, 'tlpkg')}`, 14 | '-mTeXLive::TLUtils=make_local_skeleton', 15 | '-e', 16 | 'make_local_skeleton shift', 17 | texmflocal, 18 | ]); 19 | } 20 | -------------------------------------------------------------------------------- /packages/texlive/src/version.ts: -------------------------------------------------------------------------------- 1 | import { type Range, satisfies as semverSatisfies } from 'semver'; 2 | 3 | export type Version = 4 | | `200${8 | 9}` 5 | | `20${1 | 2}${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`; 6 | 7 | export namespace Version { 8 | const RE = /^199[6-9]|20[0-2]\d$/v; 9 | 10 | export function isVersion(spec: unknown): spec is Version { 11 | return typeof spec === 'string' && RE.test(spec); 12 | } 13 | 14 | export function parse(spec: string): Version { 15 | if (!isVersion(spec)) { 16 | throw new TypeError(`\`${spec}\` is not a valid version spec`); 17 | } 18 | return spec; 19 | } 20 | 21 | function coerce(version: Version): `${Version}.0.0` { 22 | return `${version}.0.0`; 23 | } 24 | 25 | export function satisfies( 26 | version: Version, 27 | /* eslint-disable-next-line 28 | @typescript-eslint/prefer-readonly-parameter-types */ 29 | range: string | Readonly, 30 | ): boolean { 31 | return semverSatisfies(coerce(version), range); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/texlive/tsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", 3 | "extends": ["@setup-texlive-action/config/tsdoc.json"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/texlive/vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { mergeConfig } from 'vitest/config'; 2 | 3 | import sharedConfig from '@setup-texlive-action/config/vitest'; 4 | import fixtures from '@setup-texlive-action/fixtures'; 5 | 6 | export default mergeConfig(sharedConfig, { 7 | plugins: [fixtures()], 8 | test: { 9 | setupFiles: ['__tests__/setup.ts'], 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@setup-texlive-action/config/tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "#action/*": [ 7 | "./action/src/*/index.ts", 8 | "./action/src/*.ts", 9 | "./action/src/*", 10 | ], 11 | "#texlive/*": [ 12 | "./texlive/src/*/index.ts", 13 | "./texlive/src/*.ts", 14 | "./texlive/src/*", 15 | ], 16 | }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /packages/types/README.md: -------------------------------------------------------------------------------- 1 | # @types/setup-texlive-action 2 | 3 | > Global type definitions for third-party resources 4 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@types/setup-texlive-action", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "types": "src/index.d.ts" 7 | } 8 | -------------------------------------------------------------------------------- /packages/types/src/array-from-async.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module 'array-from-async' { 4 | declare const fromAsync: typeof Array.fromAsync; 5 | export default fromAsync; 6 | } 7 | -------------------------------------------------------------------------------- /packages/types/src/class-transformer.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'class-transformer/esm5/storage' { 2 | export * from 'class-transformer/types/storage.js'; 3 | } 4 | -------------------------------------------------------------------------------- /packages/types/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | namespace NodeJS { 4 | interface ProcessEnv { 5 | /** 6 | * @see {@link https://no-color.org/} 7 | */ 8 | NO_COLOR?: string; 9 | 10 | /** 11 | * @see {@link https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables} 12 | */ 13 | GITHUB_ACTIONS?: string; 14 | GITHUB_ACTION_PATH?: string; 15 | GITHUB_WORKSPACE?: string; 16 | RUNNER_DEBUG?: string; 17 | RUNNER_TEMP?: string; 18 | 19 | /** 20 | * @see {@link https://tug.org/texlive/doc/install-tl.html#ENVIRONMENT-VARIABLES} 21 | */ 22 | TEXLIVE_DOWNLOADER?: string; 23 | TL_DOWNLOAD_PROGRAM?: string; 24 | TL_DOWNLOAD_ARGS?: string; 25 | TEXLIVE_INSTALL_ENV_NOCHECK?: string; 26 | TEXLIVE_INSTALL_NO_CONTEXT_CACHE?: string; 27 | TEXLIVE_INSTALL_NO_DISKCHECK?: string; 28 | TEXLIVE_INSTALL_NO_RESUME?: string; 29 | TEXLIVE_INSTALL_NO_WELCOME?: string; 30 | TEXLIVE_INSTALL_PAPER?: string; 31 | TEXLIVE_INSTALL_PREFIX?: string; 32 | TEXLIVE_INSTALL_TEXDIR?: string; 33 | TEXLIVE_INSTALL_TEXMFLOCAL?: string; 34 | TEXLIVE_INSTALL_TEXMFSYSCONFIG?: string; 35 | TEXLIVE_INSTALL_TEXMFSYSVAR?: string; 36 | TEXLIVE_INSTALL_TEXMFHOME?: string; 37 | TEXLIVE_INSTALL_TEXMFCONFIG?: string; 38 | TEXLIVE_INSTALL_TEXMFVAR?: string; 39 | NOPERLDOC?: string; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/types/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | interface Error { 2 | [key: string]: unknown; // Some additional information. 3 | } 4 | -------------------------------------------------------------------------------- /packages/types/src/index.d.ts: -------------------------------------------------------------------------------- 1 | import './array-from-async.d.ts'; 2 | import './class-transformer.d.ts'; 3 | import './env.d.ts'; 4 | import './globals.d.ts'; 5 | import './node.d.ts'; 6 | -------------------------------------------------------------------------------- /packages/types/src/node.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'node:net' { 2 | function setDefaultAutoSelectFamily(value: boolean): void; 3 | } 4 | -------------------------------------------------------------------------------- /packages/utils/README.md: -------------------------------------------------------------------------------- 1 | # @setup-texlive-action/utils 2 | 3 | > Module for common utilities 4 | -------------------------------------------------------------------------------- /packages/utils/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import type { Dirent } from 'node:fs'; 4 | import * as fs from 'node:fs/promises'; 5 | 6 | import * as tool from '@actions/tool-cache'; 7 | import '@setup-texlive-action/polyfill'; 8 | import { extract } from '@setup-texlive-action/utils'; 9 | 10 | vi.mock('node:fs/promises', () => ({ 11 | readdir: vi.fn().mockResolvedValue(['']), 12 | })); 13 | vi.mock('node:path', async () => await import('node:path/posix')); 14 | vi.mock('@actions/tool-cache'); 15 | 16 | describe('extract', () => { 17 | it('extracts files from a tarball', async () => { 18 | vi.mocked(tool.extractTar).mockResolvedValueOnce(''); 19 | await expect(extract('', 'tgz')).resolves.toBe( 20 | '', 21 | ); 22 | expect(tool.extractTar).toHaveBeenCalledWith('', undefined, [ 23 | 'xz', 24 | '--strip=1', 25 | ]); 26 | }); 27 | 28 | it('extracts files from a zipfile', async () => { 29 | vi.spyOn(tool, 'extractZip').mockResolvedValueOnce(''); 30 | await expect(extract('', 'zip')).resolves.not.toThrow(); 31 | expect(tool.extractZip).toHaveBeenCalledWith(''); 32 | }); 33 | 34 | it.each([[[]], [['', '']]])( 35 | 'throws an exception if the directory cannot be located', 36 | async (files) => { 37 | vi.spyOn(fs, 'readdir').mockResolvedValueOnce( 38 | files as unknown as Dirent[], 39 | ); 40 | vi.spyOn(tool, 'extractZip').mockResolvedValueOnce(''); 41 | await expect(extract('', 'zip')).rejects.toThrow( 42 | 'Unable to locate unzipped subdirectory', 43 | ); 44 | }, 45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@setup-texlive-action/utils", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "test": "vitest" 8 | }, 9 | "exports": { 10 | ".": "./src/index.ts", 11 | "./exec": "./src/exec.ts", 12 | "./http": "./src/http.ts", 13 | "./id": "./src/id.ts" 14 | }, 15 | "sideEffects": false, 16 | "dependencies": { 17 | "@actions/exec": "^1.1.1", 18 | "@actions/http-client": "^2.2.3", 19 | "@actions/io": "^1.1.3", 20 | "@actions/tool-cache": "^2.0.2", 21 | "class-transformer": "^0.5.1", 22 | "scule": "^1.3.0", 23 | "ts-pattern": "^5.7.1" 24 | }, 25 | "devDependencies": { 26 | "@setup-texlive-action/polyfill": "*" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/utils/src/__mocks__/exec.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | import { ExecResult } from '../exec.js'; 4 | 5 | export const exec = vi.fn(async (command: string, args?: readonly string[]) => 6 | new ExecResult({ 7 | command, 8 | args, 9 | stderr: '', 10 | stdout: '', 11 | exitCode: 0, 12 | }) 13 | ); 14 | 15 | export { ExecError, ExecResult } from '../exec.js'; 16 | -------------------------------------------------------------------------------- /packages/utils/src/__mocks__/fs.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const extract = vi.fn().mockResolvedValue(''); 4 | export const uniqueChild = vi.fn(); 5 | export const mkdtemp = vi.fn(); 6 | 7 | export { tmpdir } from '../fs.js'; 8 | -------------------------------------------------------------------------------- /packages/utils/src/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exec.js'; 2 | export * from './fs.js'; 3 | 4 | export * from '../decorators.js'; 5 | export * as http from '../http.js'; 6 | export { default as id } from '../id.js'; 7 | export * as string from '../string.js'; 8 | export * from '../types.js'; 9 | -------------------------------------------------------------------------------- /packages/utils/src/decorators.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import { env } from 'node:process'; 3 | 4 | import { Expose, Transform } from 'class-transformer'; 5 | import { 6 | defaultMetadataStorage as storage, 7 | } from 'class-transformer/esm5/storage'; 8 | import { kebabCase, snakeCase } from 'scule'; 9 | 10 | export function Exception(constructor: F): void { 11 | const name = constructor.name; 12 | Object.defineProperties(constructor.prototype, { 13 | name: { 14 | value: name, 15 | }, 16 | [Symbol.toStringTag]: { 17 | get: function(this: Readonly) { 18 | return this.name; 19 | }, 20 | }, 21 | toJSON: { 22 | value: function() { 23 | return {}; 24 | }, 25 | }, 26 | }); 27 | } 28 | 29 | const CASE = { 30 | kebab: kebabCase, 31 | snake: snakeCase, 32 | } as const; 33 | 34 | /** Serialize/deserialize properties with different letter cases. */ 35 | export function Case( 36 | letterCase: keyof typeof CASE, 37 | ): PropertyDecorator & ClassDecorator { 38 | function decorator(target: F): void; 39 | function decorator(target: object, key: string | symbol): void; 40 | function decorator(target: object, key?: string | symbol): void { 41 | const metadatas = storage.getExposedMetadatas( 42 | // eslint-disable-next-line unicorn/no-instanceof-builtins 43 | target instanceof Function ? target : target.constructor, 44 | ); 45 | if (key !== undefined) { 46 | const name = CASE[letterCase](key as string); 47 | const metadata = metadatas.find((data) => data.propertyName === key); 48 | if (metadata === undefined) { 49 | Expose({ name })(target, key); 50 | } else { 51 | metadata.options.name = name; 52 | } 53 | } else { 54 | for (const metadata of metadatas) { 55 | if (metadata.propertyName !== undefined) { 56 | metadata.options.name = CASE[letterCase](metadata.propertyName); 57 | } 58 | } 59 | } 60 | } 61 | return decorator; 62 | } 63 | 64 | export function getExposedName(target: object, key: string | symbol): string { 65 | return storage 66 | .getExposedMetadatas(target.constructor) 67 | .find((data) => data.propertyName === key) 68 | ?.options 69 | .name ?? (key as string); 70 | } 71 | 72 | /** Read initial values from environment variables. */ 73 | export function FromEnv(key: string): PropertyDecorator { 74 | return Transform(({ value }) => { 75 | return env[key] ?? (value === undefined ? undefined : assertString(value)); 76 | }); 77 | } 78 | 79 | /** Read a string value as path. */ 80 | export const AsPath: PropertyDecorator = Transform(({ value }) => { 81 | return value === undefined ? undefined : path.normalize(assertString(value)); 82 | }); 83 | 84 | function assertString(value: unknown): string { 85 | if (typeof value === 'string') { 86 | return value; 87 | // eslint-disable-next-line unicorn/no-instanceof-builtins 88 | } else if (value instanceof String) { 89 | return value.valueOf(); 90 | } 91 | const error = new TypeError('Unexpectedly non-string passed'); 92 | error['input'] = value; 93 | throw error; 94 | } 95 | 96 | /* eslint @typescript-eslint/no-unsafe-function-type: off */ 97 | -------------------------------------------------------------------------------- /packages/utils/src/exec.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer'; 2 | 3 | import { 4 | type ExecOptions as ActionsExecOptions, 5 | type ExecOutput, 6 | getExecOutput, 7 | } from '@actions/exec'; 8 | import { P, match } from 'ts-pattern'; 9 | 10 | import { Exception } from './decorators.js'; 11 | import { type Nullish } from './types.js'; 12 | 13 | export interface ExecOptions 14 | extends Readonly> 15 | { 16 | readonly stdin?: Buffer | string | null; 17 | } 18 | 19 | export type ExecResultConfig = 20 | & Omit 21 | & Nullish>; 22 | 23 | export class ExecResult implements ExecOutput { 24 | readonly command: string; 25 | declare readonly args?: readonly string[]; 26 | readonly exitCode: number; 27 | readonly stderr: string; 28 | readonly stdout: string; 29 | declare readonly silenced: boolean; 30 | 31 | constructor(config: ExecResultConfig) { 32 | this.command = config.command; 33 | if (config.args !== undefined) { 34 | this.args = config.args; 35 | } 36 | this.exitCode = config.exitCode; 37 | this.stderr = config.stderr; 38 | this.stdout = config.stdout; 39 | Object.defineProperty(this, 'silenced', { 40 | value: config.silenced ?? false, 41 | }); 42 | } 43 | 44 | check(): void { 45 | if (this.exitCode !== 0) { 46 | throw new ExecError(this); 47 | } 48 | } 49 | } 50 | 51 | export interface ExecError extends Omit {} 52 | 53 | @Exception 54 | export class ExecError extends Error { 55 | constructor(config: Nullish) { 56 | const { command, exitCode, stderr, silenced = false } = config; 57 | super(`\`${command}\` exited with status ${exitCode}: ${stderr}`); 58 | Object.assign(this, config); 59 | // Prevents `stderr` from inspecting as it is included in the error message. 60 | Object.defineProperty(this, 'stderr', { enumerable: false }); 61 | if (!silenced) { 62 | // Prevents `stdout` from inspecting as it is output to the log. 63 | Object.defineProperty(this, 'stdout', { enumerable: false }); 64 | } 65 | } 66 | } 67 | 68 | export async function exec( 69 | command: string, 70 | // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types 71 | args?: string[], 72 | // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types 73 | options?: ExecOptions, 74 | ): Promise { 75 | const { stdin, ...rest } = options ?? {}; 76 | const execOptions: ActionsExecOptions = { ...rest, ignoreReturnCode: true }; 77 | if (stdin !== undefined) { 78 | execOptions.input = match(stdin) 79 | .with(null, () => Buffer.alloc(0)) // eslint-disable-line unicorn/no-null 80 | .with(P.string, (input) => Buffer.from(input)) 81 | .with(P.instanceOf(Buffer), (input) => input) 82 | .exhaustive(); 83 | } 84 | const outputs = await getExecOutput(command, args, execOptions); 85 | const result = new ExecResult({ 86 | command, 87 | args, 88 | ...outputs, 89 | silenced: options?.silent, 90 | }); 91 | if (options?.ignoreReturnCode !== true) { 92 | result.check(); 93 | } 94 | return result; 95 | } 96 | 97 | export type { ExecOutput }; 98 | -------------------------------------------------------------------------------- /packages/utils/src/fs.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | import { tmpdir as osTmpdir } from 'node:os'; 3 | import * as path from 'node:path'; 4 | import { env } from 'node:process'; 5 | 6 | import { rmRF } from '@actions/io'; 7 | import { extractTar, extractZip } from '@actions/tool-cache'; 8 | 9 | import id from './id.js'; 10 | 11 | /** 12 | * Extracts files from an archive. 13 | * 14 | * @returns Path to the directory containing the files. 15 | */ 16 | export async function extract( 17 | archive: string, 18 | kind: 'zip' | 'tgz', 19 | ): Promise { 20 | switch (kind) { 21 | case 'tgz': { 22 | return await extractTar(archive, undefined, ['xz', '--strip=1']); 23 | } 24 | case 'zip': { 25 | const parent = await extractZip(archive); 26 | try { 27 | return await uniqueChild(parent); 28 | } catch (cause) { 29 | throw new Error('Unable to locate unzipped subdirectory', { cause }); 30 | } 31 | } 32 | } 33 | } 34 | 35 | export async function uniqueChild(parent: string): Promise { 36 | const [child, ...rest] = await fs.readdir(parent); 37 | if (child === undefined) { 38 | throw new Error(`${parent} has no entries`); 39 | } 40 | if (rest.length > 0) { 41 | throw new Error(`${parent} has multiple entries`); 42 | } 43 | return path.join(parent, child); 44 | } 45 | 46 | export function tmpdir(): string { 47 | return env.RUNNER_TEMP ?? osTmpdir(); 48 | } 49 | 50 | export interface Tmpdir extends AsyncDisposable { 51 | readonly path: string; 52 | } 53 | 54 | export async function mkdtemp(): Promise { 55 | return { 56 | path: await fs.mkdtemp(path.join(tmpdir(), `${id['kebab-case']}-`)), 57 | async [Symbol.asyncDispose](this: Tmpdir): Promise { 58 | await rmRF(this.path); 59 | }, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /packages/utils/src/http.ts: -------------------------------------------------------------------------------- 1 | import { type IncomingHttpHeaders, STATUS_CODES } from 'node:http'; 2 | 3 | import { 4 | HttpClient as ActionsHttpClient, 5 | HttpClientError, 6 | HttpCodes, 7 | } from '@actions/http-client'; 8 | 9 | export class HttpClient extends ActionsHttpClient implements Disposable { 10 | [Symbol.dispose](): void { 11 | this.dispose(); 12 | } 13 | } 14 | 15 | export async function getJson(url: string | Readonly): Promise { 16 | using http = new HttpClient(); 17 | const { result, statusCode } = await http.getJson(url.toString()); 18 | if (statusCode !== HttpCodes.OK) { 19 | throw createClientError(statusCode, url); 20 | } 21 | // `result` should be non-null unless the status is 404. 22 | return result!; 23 | } 24 | 25 | export async function getHeaders( 26 | url: string | Readonly, 27 | ): Promise { 28 | using http = new HttpClient(); 29 | const { message } = await http.head(url.toString()); 30 | const { headers, statusCode = Number.NaN } = message.destroy(); 31 | if (statusCode !== HttpCodes.OK) { 32 | throw createClientError(statusCode, url); 33 | } 34 | return headers; 35 | } 36 | 37 | export function createClientError( 38 | statusCode: number, 39 | url: string | Readonly, 40 | ): HttpClientError { 41 | let msg = `${url} returned ${statusCode}`; 42 | if (statusCode in STATUS_CODES) { 43 | msg += `: ${STATUS_CODES[statusCode]}`; 44 | } 45 | // https://nodejs.org/api/errors.html#errorcapturestacktracetargetobject-constructoropt 46 | const { stackTraceLimit } = Error; 47 | Error.stackTraceLimit = 0; 48 | const error = new HttpClientError(msg, statusCode); 49 | Error.stackTraceLimit = stackTraceLimit; 50 | Error.captureStackTrace(error, createClientError); 51 | return error; 52 | } 53 | 54 | export { HttpCodes }; 55 | 56 | /* eslint @typescript-eslint/no-unsafe-enum-comparison: off */ 57 | -------------------------------------------------------------------------------- /packages/utils/src/id.ts: -------------------------------------------------------------------------------- 1 | import { snakeCase } from 'scule'; 2 | 3 | import { toUpperCase } from './string.js'; 4 | 5 | const name = 'setup-texlive-action'; 6 | 7 | export const SCREAMING_SNAKE_CASE = toUpperCase(snakeCase(name)); 8 | 9 | export default { 'kebab-case': name, SCREAMING_SNAKE_CASE } as const; 10 | 11 | /* eslint @typescript-eslint/naming-convention: off */ 12 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | import type {} from '@setup-texlive-action/polyfill'; 2 | 3 | export * from './decorators.js'; 4 | export * from './exec.js'; 5 | export * from './fs.js'; 6 | export * as http from './http.js'; 7 | export { default as id } from './id.js'; 8 | export * as string from './string.js'; 9 | export * from './types.js'; 10 | -------------------------------------------------------------------------------- /packages/utils/src/string.ts: -------------------------------------------------------------------------------- 1 | export function toUpperCase(text: T): Uppercase { 2 | return text.toUpperCase() as Uppercase; 3 | } 4 | -------------------------------------------------------------------------------- /packages/utils/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Strict = 2 | & Omit 3 | & { [K in Keys]-?: NonNullable }; 4 | 5 | export type Nullish = 6 | & Omit 7 | & { [K in Keys]?: T[K] | undefined }; 8 | 9 | export function isIterable(value: unknown): value is Iterable { 10 | return typeof ( 11 | (value as Record | null | undefined)?.[Symbol.iterator] 12 | ) === 'function'; 13 | } 14 | -------------------------------------------------------------------------------- /packages/utils/vitest.config.mjs: -------------------------------------------------------------------------------- 1 | export { default } from '@setup-texlive-action/config/vitest'; 2 | -------------------------------------------------------------------------------- /packages/vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { mergeConfig } from 'vitest/config'; 2 | 3 | import sharedConfig from '@setup-texlive-action/config/vitest'; 4 | 5 | export default mergeConfig(sharedConfig, { 6 | test: { 7 | include: [], 8 | workspace: [ 9 | '*/vitest.config.{js,mjs,ts}', 10 | '!e2e/vitest.config.mjs', 11 | ], 12 | server: { 13 | deps: { 14 | cacheDir: '../node_modules/.vite', 15 | }, 16 | }, 17 | coverage: { 18 | enabled: true, 19 | include: ['*/src/**/*.ts'], 20 | exclude: ['**/__mocks__/**', '**/*.d.ts'], 21 | reportsDirectory: '../coverage', 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /scripts/build.mjs: -------------------------------------------------------------------------------- 1 | import { mkdir, writeFile } from 'node:fs/promises'; 2 | import { createRequire } from 'node:module'; 3 | import path from 'node:path'; 4 | import { env } from 'node:process'; 5 | 6 | import esbuild from 'esbuild'; 7 | 8 | import esbuildConfig from '@setup-texlive-action/config/esbuild'; 9 | 10 | const packageJson = createRequire(import.meta.url)(env.npm_package_json); 11 | 12 | const { metafile } = await esbuild.build({ 13 | ...esbuildConfig, 14 | entryPoints: ['./packages/action'], 15 | tsconfig: './packages/tsconfig.json', 16 | outfile: packageJson.main, 17 | sourcemap: 'linked', 18 | sourcesContent: false, 19 | metafile: true, 20 | }); 21 | 22 | const outdir = path.join('node_modules', '.esbuild'); 23 | await mkdir(outdir, { recursive: true }); 24 | await writeFile( 25 | path.join(outdir, 'meta.json'), 26 | JSON.stringify(metafile, undefined, 2), 27 | ); 28 | await writeFile( 29 | path.join(outdir, 'meta.txt'), 30 | await esbuild.analyzeMetafile(metafile, { verbose: true }), 31 | ); 32 | -------------------------------------------------------------------------------- /scripts/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # shellcheck disable=SC2154 5 | readonly version="v${npm_package_version}" 6 | export RUST_LOG="${RUST_LOG:-warn}" 7 | 8 | # shellcheck disable=SC2154 9 | case "${npm_lifecycle_event}" in 10 | version) 11 | git ls-files -z dist | 12 | xargs -0 git update-index --no-assume-unchanged -- 13 | git add dist package-lock.json package.json 14 | git commit -m "chore(release): prepare for ${version}" 15 | ;; 16 | 17 | postversion) 18 | git ls-files -z dist | 19 | xargs -0 git update-index --assume-unchanged -- 20 | 21 | npm run --silent changelog -- --tag "${version}" | 22 | git tag "${version}" --cleanup=whitespace -F - 23 | git tag -f "${version%%.*}" -m "${version}" 24 | 25 | git --no-pager show --color --no-patch "${version}" | 26 | sed '/^-----BEGIN/,/^-----END/d' 27 | echo 28 | ;; 29 | esac 30 | -------------------------------------------------------------------------------- /scripts/generate-matrix.jq: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S jq -crf 2 | .current.version | tonumber as $latest | null | 3 | 4 | def default_runners: "ubuntu-latest", "windows-latest", "mac-latest"; 5 | 6 | def min_supported_version: 7 | if test("^ubuntu-.*-arm$") then 8 | 2017 9 | elif startswith("macos-") then 10 | 2013 11 | else 12 | 2008 13 | end; 14 | 15 | [ 16 | env.runner | select(. != "") // default_runners 17 | | { 18 | runner: ., 19 | "texlive-version": range(min_supported_version; $latest) 20 | | tostring 21 | | select(["", null, .] | any(. == env."texlive-version")) 22 | } 23 | ] 24 | -------------------------------------------------------------------------------- /scripts/run.mjs: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'node:child_process'; 2 | import fs from 'node:fs/promises'; 3 | import os from 'node:os'; 4 | import path from 'node:path'; 5 | import process from 'node:process'; 6 | import { fileURLToPath } from 'node:url'; 7 | import { parseArgs } from 'node:util'; 8 | 9 | import esbuild from 'esbuild'; 10 | 11 | import esbuildConfig from '@setup-texlive-action/config/esbuild'; 12 | 13 | const script = path.basename(fileURLToPath(import.meta.url), ''); 14 | 15 | /** @yields {string} */ 16 | const mkdtemp = async function*() { 17 | const prefix = path.join(os.tmpdir(), `${script}-`); 18 | const tmpdir = await fs.mkdtemp(prefix); 19 | try { 20 | yield tmpdir; 21 | } finally { 22 | await fs.rm(tmpdir, { recursive: true }); 23 | } 24 | }; 25 | 26 | /** 27 | * @param {string} input 28 | * @param {Iterable} args 29 | * @return {Promise} 30 | */ 31 | const node = async (main, args) => { 32 | const { status, signal } = spawnSync('node', [ 33 | '--enable-source-maps', 34 | '--', 35 | main, 36 | ...args, 37 | ], { 38 | stdio: 'inherit', 39 | }); 40 | return status ?? (signal + 128); 41 | }; 42 | 43 | /** @returns {Promise} */ 44 | const run = async () => { 45 | const { positionals } = parseArgs({ allowPositionals: true }); 46 | if (positionals.length === 0) { 47 | console.error(`USAGE: ${script} main.ts [...]`); 48 | return 1; 49 | } 50 | const [input, ...args] = positionals; 51 | 52 | for await (const tmpdir of mkdtemp()) { 53 | const outfile = path.format({ 54 | dir: tmpdir, 55 | name: path.basename(input), 56 | ext: '.mjs', 57 | }); 58 | try { 59 | await esbuild.build({ 60 | ...esbuildConfig, 61 | entryPoints: [input], 62 | outfile, 63 | minify: true, 64 | keepNames: true, 65 | sourcemap: 'inline', 66 | logLevel: 'warning', 67 | }); 68 | } catch { 69 | return 1; 70 | } 71 | return await node(outfile, args); 72 | } 73 | }; 74 | 75 | process.exit(await run()); 76 | --------------------------------------------------------------------------------