diff --git a/README.md b/README.md index 8505732..5bfe2b3 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,7 @@ All inputs are optional. | `package-file` | String | [Glob patterns] for specifying files containing the names of TeX packages to be installed. The file format should be the same as the syntax for the `packages` input. The [`DEPENDS.txt`] format is also supported. | | `packages` | String | Specify the names of TeX packages to install, separated by whitespaces. Schemes and collections are also acceptable. Everything after `#` will be treated as a comment. | | `prefix` | String |

TeX Live installation prefix. This has the same effect as [`TEXLIVE_INSTALL_PREFIX`][install-tl-env].

**Default:** [$RUNNER_TEMP]/setup-texlive-action | +| `repository` | URL | Specify the package repository to be used as the main repository. Currently only http/https repositories are supported. | | `texdir` | String | TeX Live system installation directory. This has the same effect as the installer's [`-texdir`] option and takes precedence over the `prefix` input and related environment variables. | | `tlcontrib` | Bool |

Set up [TLContrib] as an additional TeX package repository. This input will be ignored for older versions.

**Default:** `false` | | `update-all-packages` | Bool |

Update all TeX packages when cache restored. Defaults to `false`, and the action will update only `tlmgr`.

**Default:** `false` | diff --git a/action.yml b/action.yml index 7b55fb5..80a5dad 100644 --- a/action.yml +++ b/action.yml @@ -5,7 +5,7 @@ inputs: cache: description: >- Enable caching for `TEXDIR`. - default: true + default: 'true' required: false package-file: description: >- @@ -28,6 +28,11 @@ inputs: This has the same effect as `TEXLIVE_INSTALL_PREFIX`. Defaults to `$RUNNER_TEMP/setup-texlive-action`. required: false + repository: + description: >- + Specify the package repository to be used as the main repository. + Currently only http/https repositories are supported. + required: false texdir: description: >- TeX Live system installation directory. @@ -39,13 +44,13 @@ inputs: description: >- Set up TLContrib as an additional TeX package repository. This input will be ignored for older versions. - default: false + default: 'false' required: false update-all-packages: description: >- Update all TeX packages when cache restored. The default is `false` and the action will update only `tlmgr`. - default: false + default: 'false' required: false version: description: >- diff --git a/package-lock.json b/package-lock.json index 6072e2c..ebf012b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "setup-texlive-action", "version": "3.0.2", - "hasInstallScript": true, "license": "MIT", "workspaces": [ "packages/*" @@ -8932,6 +8931,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/texlive-json-schemas": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/texlive-json-schemas/-/texlive-json-schemas-0.1.0.tgz", + "integrity": "sha512-L0uHRI13nqLFisBCi285xDj8We3XhL2+vKCY/dnqCvPPofjPlYj+3p4YXL33ODZBAvnebj/rycH2a1sHCB6UGA==", + "dev": true + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10431,6 +10436,7 @@ "@types/mock-fs": "^4.13.4", "mock-fs": "^5.2.0", "nock": "^13.5.1", + "texlive-json-schemas": "^0.1.0", "ts-dedent": "^2.2.0", "ts-essentials": "^9.4.1", "vitest": "^1.2.2" diff --git a/packages/e2e/Taskfile.yml b/packages/e2e/Taskfile.yml index 02693a1..3cf9de0 100644 --- a/packages/e2e/Taskfile.yml +++ b/packages/e2e/Taskfile.yml @@ -9,7 +9,7 @@ tasks: dir: '{{ .npm_config_local_prefix }}' cmd: >- act - --container-architecture linux/{{ ARCH }} + --container-architecture linux/{{ default ARCH .architecture }} --workflows {{ .workflows }} {{ .CLI_ARGS }} clear-cache: @@ -24,6 +24,7 @@ tasks: historic: &run-global-workflow <<: *act vars: + architecture: amd64 workflows: .github/workflows/e2e-{{ .TASK }}.yml proxy: *run-global-workflow test: @@ -38,3 +39,4 @@ tasks: basedir: '{{ .TASKFILE_DIR | relPath .npm_config_local_prefix }}' workflows: '{{ .basedir }}/workflows/{{ .TASK }}.yml' move-to-historic: *run-local-workflow + tlpretest: *run-local-workflow diff --git a/packages/e2e/workflows/tlpretest.yml b/packages/e2e/workflows/tlpretest.yml new file mode 100644 index 0000000..dcfe3a8 --- /dev/null +++ b/packages/e2e/workflows/tlpretest.yml @@ -0,0 +1,12 @@ +on: workflow_dispatch +jobs: + tlpretest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup TeX Live + uses: ./ + with: + repository: https://ftp.math.utah.edu/pub/tlpretest/ + version: 2024 + - run: tlmgr version diff --git a/packages/main/package.json b/packages/main/package.json index 3ec9781..3135de8 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -34,6 +34,7 @@ "@types/mock-fs": "^4.13.4", "mock-fs": "^5.2.0", "nock": "^13.5.1", + "texlive-json-schemas": "^0.1.0", "ts-dedent": "^2.2.0", "ts-essentials": "^9.4.1", "vitest": "^1.2.2" diff --git a/packages/main/src/action/config.ts b/packages/main/src/action/config.ts index 03a181e..082d64f 100644 --- a/packages/main/src/action/config.ts +++ b/packages/main/src/action/config.ts @@ -1,8 +1,9 @@ import { readFile } from 'node:fs/promises'; import { platform } from 'node:os'; +import * as posixPath from 'node:path/posix'; import { create as createGlobber } from '@actions/glob'; -import type { DeepUndefinable } from 'ts-essentials'; +import type { DeepUndefinable, Writable } from 'ts-essentials'; import * as env from '#/action/env'; import { Inputs } from '#/action/inputs'; @@ -10,9 +11,10 @@ import * as log from '#/log'; import { ReleaseData, Version, dependsTxt } from '#/texlive'; export interface Config - extends Omit + extends Omit { readonly packages: ReadonlySet; + readonly repository?: Readonly; readonly version: Version; } @@ -21,22 +23,50 @@ export namespace Config { env.init(); const releases = await ReleaseData.setup(); - const { packageFile, packages, version, ...inputs } = Inputs.load(); + const { + packageFile, + packages, + repository, + version, + ...inputs + } = Inputs.load(); - const config = { + const config: Writable = { ...inputs, version: await resolveVersion({ version }), packages: await collectPackages({ packageFile, packages }), }; - if (!releases.isLatest(config.version)) { + if (repository !== undefined) { + if (version < '2012') { + const error = new RangeError( + 'Currently `repository` input is only supported with version 2012 or later', + ); + error['version'] = version; + throw error; + } + const url = new URL(repository); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + const error = new TypeError( + 'Currently only http/https repositories are supported', + ); + error['repository'] = repository; + throw error; + } + if (!url.pathname.endsWith('/')) { + url.pathname = posixPath.join(url.pathname, '/'); + } + config.repository = url; + } + + if (config.version < releases.latest.version) { if (config.tlcontrib) { - log.warn(`TLContrib cannot be used with an older version of TeX Live`); + log.warn('TLContrib cannot be used with an older version of TeX Live'); config.tlcontrib = false; } if ( !( - releases.isOnePrevious(config.version) + config.version < releases.previous.version && releases.newVersionReleased() ) && config.updateAllPackages ) { @@ -77,7 +107,7 @@ async function collectPackages( async function resolveVersion( inputs: Pick, ): Promise { - const { latest } = ReleaseData.use(); + const { latest, next } = ReleaseData.use(); const version = inputs.version === 'latest' ? latest.version : Version.parse(inputs.version); @@ -89,7 +119,7 @@ async function resolveVersion( 'Versions prior to 2013 does not work on 64-bit macOS', ); } - if (version > latest.version) { + if (version > next.version) { throw new RangeError(`${version} is not a valid version`); } return version; diff --git a/packages/main/src/action/inputs.ts b/packages/main/src/action/inputs.ts index 819cf6d..bc84ea8 100644 --- a/packages/main/src/action/inputs.ts +++ b/packages/main/src/action/inputs.ts @@ -26,6 +26,9 @@ export class Inputs { @AsPath readonly prefix!: string; + @Input + readonly repository: string | undefined; + @Input @AsPath readonly texdir: string | undefined; diff --git a/packages/main/src/action/run/main/index.ts b/packages/main/src/action/run/main/index.ts index d7939cf..1bd6626 100644 --- a/packages/main/src/action/run/main/index.ts +++ b/packages/main/src/action/run/main/index.ts @@ -9,7 +9,7 @@ import { Profile, ReleaseData, Tlmgr, tlnet } from '#/texlive'; export async function main(): Promise { const config = await Config.load(); - const releases = ReleaseData.use(); + const { latest, previous, newVersionReleased } = ReleaseData.use(); await using profile = new Profile(config.version, config); using cache = CacheService.setup({ @@ -31,7 +31,7 @@ export async function main(): Promise { log.info(profile.toString()); }); await log.group('Installing TeX Live', async () => { - await install(profile); + await install({ profile, repository: config.repository }); }); } @@ -40,18 +40,15 @@ export async function main(): Promise { if (cache.restored) { if ( - releases.isLatest(profile.version) - || ( - releases.isOnePrevious(profile.version) - && releases.newVersionReleased() - ) + profile.version >= latest.version + || (profile.version === previous.version && newVersionReleased()) ) { await log.group( - releases.isLatest(profile.version) + profile.version >= latest.version ? 'Updating tlmgr' : 'Checking the package repository status', async () => { - await updateTlmgr(profile.version); + await updateTlmgr(config); }, ); if (config.updateAllPackages) { @@ -79,8 +76,8 @@ export async function main(): Promise { await log.group('TeX Live version info', async () => { await tlmgr.version(); log.info('Package version:'); - for await (const { name, version, revision } of tlmgr.list()) { - log.info(' %s: %s', name, version ?? `rev${revision}`); + for await (const { name, revision, cataloguedata } of tlmgr.list()) { + log.info(' %s: %s', name, cataloguedata?.version ?? `rev${revision}`); } }); diff --git a/packages/main/src/action/run/main/install.ts b/packages/main/src/action/run/main/install.ts index c10341e..afa2efc 100644 --- a/packages/main/src/action/run/main/install.ts +++ b/packages/main/src/action/run/main/install.ts @@ -1,41 +1,79 @@ +import { P, match } from 'ts-pattern'; + import * as log from '#/log'; import { + type InstallTL, InstallTLError, - Profile, + type Profile, ReleaseData, TlpdbError, - installTL, + acquire, tlnet, } from '#/texlive'; -export async function install(profile: Profile): Promise { - const { isLatest, isOnePrevious } = ReleaseData.use(); - for (const master of [false, true]) { - const repository = isLatest(profile.version) - ? await tlnet.ctan({ master }) - : tlnet.historic(profile.version, { master }); - log.info('Main repository: %s', repository); +export async function install(options: { + readonly profile: Profile; + readonly repository?: Readonly | undefined; +}): Promise { + const { latest, previous } = ReleaseData.use(); + const { version } = options.profile; + + let repository = options?.repository; + const fallbackToMaster = repository === undefined + && version > previous.version; + + let installTL: InstallTL; + + for (const master of fallbackToMaster ? [false, true] : [false]) { + if (repository === undefined || master) { + repository = version >= latest.version + ? await tlnet.ctan({ master }) + : tlnet.historic(version, { master }); + } + try { + installTL ??= await acquire({ repository, version }); + } catch (error) { + if ( + !master + && fallbackToMaster + && error instanceof InstallTLError + && match(error.code) + .with(InstallTLError.Code.FAILED_TO_DOWNLOAD, () => true) + .with(InstallTLError.Code.UNEXPECTED_VERSION, () => true) + .otherwise(() => false) + ) { + log.info({ error }); + continue; + } + throw error; + } + log.info('Using repository: %s', repository); try { - await installTL({ profile, repository }); + await installTL.run({ + profile: options.profile, + repository: options.repository ?? repository, + }); return; } catch (error) { - const recoverable: (InstallTLError.Code | TlpdbError.Code)[] = [ - InstallTLError.Code.FAILED_TO_DOWNLOAD, - InstallTLError.Code.INCOMPATIBLE_REPOSITORY_VERSION, - InstallTLError.Code.UNEXPECTED_VERSION, - TlpdbError.Code.FAILED_TO_INITIALIZE, - ]; if ( !master - && (isLatest(profile.version) || isOnePrevious(profile.version)) - && (error instanceof TlpdbError - || error instanceof InstallTLError) - && recoverable.includes(error.code!) + && fallbackToMaster + && match(error) + .with( + P.instanceOf(TlpdbError), + ({ code }) => code === TlpdbError.Code.FAILED_TO_INITIALIZE, + ) + .with( + P.instanceOf(InstallTLError), + ({ code }) => + code === InstallTLError.Code.INCOMPATIBLE_REPOSITORY_VERSION, + ) + .otherwise(() => false) ) { log.info({ error }); - } else { - throw error; + continue; } + throw error; } } } diff --git a/packages/main/src/action/run/main/update.ts b/packages/main/src/action/run/main/update.ts index 79cea06..2189411 100644 --- a/packages/main/src/action/run/main/update.ts +++ b/packages/main/src/action/run/main/update.ts @@ -13,68 +13,90 @@ import { tlnet, } from '#/texlive'; -export async function updateTlmgr(version: Version): Promise { - const tlmgr = Tlmgr.use(); - const { isOnePrevious } = ReleaseData.use(); +export interface UpdateOptions { + readonly version: Version; + readonly repository?: Readonly | undefined; +} + +export async function updateTlmgr(options: UpdateOptions): Promise { try { - await tlmgr.update({ self: true }); - return; + await updateRepositories(options); } catch (error) { - const tlcontrib = 'tlcontrib'; if ( - isOnePrevious(version) - && error instanceof TlmgrError - && ( - error.code === TlmgrError.Code.TL_VERSION_OUTDATED - || ( - error.code === TlmgrError.Code.TL_VERSION_NOT_SUPPORTED - && 'repository' in error - && error.repository.includes(tlcontrib) - ) - ) + error instanceof TlmgrError + && error.code === TlmgrError.Code.TL_VERSION_OUTDATED + && options.repository === undefined ) { log.info({ error }); - try { - log.info('Removing `%s`', tlcontrib); - await tlmgr.repository.remove(tlcontrib); - await tlmgr.update({ self: true }); - } catch (error) { // eslint-disable-line @typescript-eslint/no-shadow - log.info(`${error}`); - log.debug({ error }); + await moveToHistoric(options.version); + } else { + throw error; + } + } +} + +async function updateRepositories(options: UpdateOptions): Promise { + const tlmgr = Tlmgr.use(); + const { latest, previous } = ReleaseData.use(); + const version = options.version; + let repository = options.repository; + if (version >= previous.version) { + for await (const { path, tag } of tlmgr.repository.list()) { + if ( + tag === 'main' + && path.includes('tlpretest') + && repository === undefined + && version === latest.version + ) { + repository = await tlnet.ctan(); + } else if ( + (tag === 'tlcontrib' || path.includes('tlcontrib')) + && version < latest.version + ) { + log.info(`Removing %s`, tag ?? path); + await tlmgr.repository.remove(tag ?? path); } } } + if (repository !== undefined) { + await changeRepository('main', repository); + } else { + await tlmgr.update({ self: true }); + } +} + +async function moveToHistoric(version: Version): Promise { + const cache = CacheService.use(); + const tag = 'main'; try { - await setupHistoric(version); + await changeRepository(tag, tlnet.historic(version)); } catch (error) { if ( error instanceof TlpdbError && error.code === TlpdbError.Code.FAILED_TO_INITIALIZE ) { log.info({ error }); - await setupHistoric(version, { master: true }); + await changeRepository(tag, tlnet.historic(version, { master: true })); } else { throw error; } } - CacheService.use().update(); + cache.update(); } -export async function setupHistoric( - version: Version, - options?: { readonly master?: boolean }, +async function changeRepository( + tag: string, + url: Readonly, ): Promise { const tlmgr = Tlmgr.use(); - const tag = 'main'; - const historic = tlnet.historic(version, options); - log.info('Changing the %s repository to %s', tag, historic.href); - if (historic.protocol === 'ftp:' && getProxyUrl(historic.href) !== '') { + log.info('Changing the repository `%s` to %s', tag, url.href); + if (url.protocol === 'ftp:' && getProxyUrl(url.href) !== '') { throw new Error( 'The use of ftp repositories under proxy is currently not supported', ); } await tlmgr.repository.remove(tag); - await tlmgr.repository.add(historic, tag); + await tlmgr.repository.add(url, tag); await tlmgr.update({ self: true }); } diff --git a/packages/main/src/texlive/install-tl/cli.ts b/packages/main/src/texlive/install-tl/cli.ts index cde4b51..b82fc2f 100644 --- a/packages/main/src/texlive/install-tl/cli.ts +++ b/packages/main/src/texlive/install-tl/cli.ts @@ -17,38 +17,41 @@ export interface InstallTLOptions { readonly repository: Readonly; } -export async function installTL(options: InstallTLOptions): Promise { - const { isLatest } = ReleaseData.use(); - const { profile, repository } = options; - const version = profile.version; - - const installTLPath = await acquire(options); - await exec(installTLPath, ['-version'], { stdin: null }); - - const result = await exec( - installTLPath, - await Array.fromAsync(commandArgs(options)), - { stdin: null, ignoreReturnCode: true }, - ); +export class InstallTL { + // eslint-disable-next-line @typescript-eslint/no-shadow + constructor(readonly path: string, readonly version: Version) {} - const errorOptions = { version, repository }; - if (isLatest(version)) { - InstallTLError.checkCompatibility(result, errorOptions); - } else { - TlpdbError.checkRepositoryStatus(result, errorOptions); - TlpdbError.checkRepositoryHealth(result, errorOptions); - } - TlpdbError.checkPackageChecksumMismatch(result, errorOptions); - try { - result.check(); - } catch (cause) { - throw new InstallTLError('Failed to install TeX Live', { - ...errorOptions, - cause, - }); - } + async run(options: InstallTLOptions): Promise { + const { latest } = ReleaseData.use(); + const { profile, repository } = options; + + await exec(this.path, ['-version'], { stdin: null }); + + const result = await exec( + this.path, + await Array.fromAsync(commandArgs(options)), + { stdin: null, ignoreReturnCode: true }, + ); + + const errorOptions = { version: this.version, repository }; + if (this.version >= latest.version) { + InstallTLError.checkCompatibility(result, errorOptions); + } else { + TlpdbError.checkRepositoryStatus(result, errorOptions); + TlpdbError.checkRepositoryHealth(result, errorOptions); + } + TlpdbError.checkPackageChecksumMismatch(result, errorOptions); + try { + result.check(); + } catch (cause) { + throw new InstallTLError('Failed to install TeX Live', { + ...errorOptions, + cause, + }); + } - await patch(profile); + await patch(profile); + } } const supportVersions = { @@ -95,19 +98,24 @@ async function* commandArgs( ]; } -async function acquire(options: InstallTLOptions): Promise { - return path.format({ +export interface DownloadOptions { + readonly version: Version; + readonly repository: Readonly; +} + +export async function acquire(options: DownloadOptions): Promise { + const installerPath = path.format({ dir: restoreCache(options) ?? await download(options), - base: executableName(options.profile.version), + base: executableName(options.version), }); + return new InstallTL(installerPath, options.version); } /** @internal */ -export function restoreCache(options: InstallTLOptions): string | undefined { - const { profile: { version } } = options; - const executable = executableName(version); +export function restoreCache(options: DownloadOptions): string | undefined { + const executable = executableName(options.version); try { - const TEXMFROOT = findTool(executable, version); + const TEXMFROOT = findTool(executable, options.version); if (TEXMFROOT !== '') { log.info('Found in tool cache: %s', TEXMFROOT); return TEXMFROOT; @@ -119,9 +127,9 @@ export function restoreCache(options: InstallTLOptions): string | undefined { } /** @internal */ -export async function download(options: InstallTLOptions): Promise { - const { isLatest } = ReleaseData.use(); - const { profile: { version }, repository } = options; +export async function download(options: DownloadOptions): Promise { + const { latest } = ReleaseData.use(); + const { version, repository } = options; const errorOpts = { repository, version, @@ -156,7 +164,7 @@ export async function download(options: InstallTLOptions): Promise { archivePath, platform() === 'win32' ? 'zip' : 'tgz', ); - if (isLatest(version)) { + if (version >= latest.version) { try { await InstallTLError.checkVersion(texmfroot, { version, repository }); } catch (error) { diff --git a/packages/main/src/texlive/install-tl/index.ts b/packages/main/src/texlive/install-tl/index.ts index f6bf28e..3cf0fb2 100644 --- a/packages/main/src/texlive/install-tl/index.ts +++ b/packages/main/src/texlive/install-tl/index.ts @@ -1,4 +1,4 @@ -export { type InstallTLOptions, installTL } from '#/texlive/install-tl/cli'; +export * from '#/texlive/install-tl/cli'; export * from '#/texlive/install-tl/env'; export * from '#/texlive/install-tl/errors'; export * from '#/texlive/install-tl/profile'; diff --git a/packages/main/src/texlive/releases.ts b/packages/main/src/texlive/releases.ts index 558973e..f8156aa 100644 --- a/packages/main/src/texlive/releases.ts +++ b/packages/main/src/texlive/releases.ts @@ -14,14 +14,14 @@ type ZonedDateTime = Temporal.ZonedDateTime; export interface Release { readonly version: Version; - readonly releaseDate: ZonedDateTime | undefined; + readonly releaseDate?: ZonedDateTime | undefined; } export interface ReleaseData { readonly newVersionReleased: () => boolean; + readonly previous: Release; readonly latest: Release; - readonly isLatest: (version: Version) => boolean; - readonly isOnePrevious: (version: Version) => boolean; + readonly next: Release; } export namespace ReleaseData { @@ -36,14 +36,13 @@ export namespace ReleaseData { function newVersionReleased(): boolean { return data.latest.version === latest.version; } - function isLatest(version: Version): boolean { - return version === latest.version; - } - function isOnePrevious(version: Version): boolean { - return Number.parseInt(version, 10) + 1 - === Number.parseInt(latest.version, 10); - } - const releases = { newVersionReleased, latest, isLatest, isOnePrevious }; + const latestVersionNumber = Number.parseInt(latest.version, 10); + const releases = { + newVersionReleased, + previous: { version: `${latestVersionNumber - 1}` as Version }, + latest, + next: { version: `${latestVersionNumber + 1}` as Version }, + }; ctx.set(releases); return releases; } diff --git a/packages/main/src/texlive/tlmgr/actions/list.ts b/packages/main/src/texlive/tlmgr/actions/list.ts index 323bba7..4328e32 100644 --- a/packages/main/src/texlive/tlmgr/actions/list.ts +++ b/packages/main/src/texlive/tlmgr/actions/list.ts @@ -1,21 +1,17 @@ import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; +import { P, match } from 'ts-pattern'; + import * as log from '#/log'; import { use } from '#/texlive/tlmgr/internals'; -import { type Tlpobj, tlpdb } from '#/texlive/tlpkg'; - -const RE = { - nonPackage: /(?:^(?:collection|scheme)-|\.)/v, - version: /^catalogue-version\s+(\S.*)$/mv, - revision: /^revision\s+(\d+)\s*$/mv, -} as const; +import { type TLPObj, tlpdb } from '#/texlive/tlpkg'; /** * Lists packages by reading `texlive.tlpdb` directly * instead of running `tlmgr list`. */ -export async function* list(): AsyncGenerator { +export async function* list(): AsyncGenerator { const tlpdbPath = path.join(use().TEXDIR, 'tlpkg', 'texlive.tlpdb'); let db: string; try { @@ -25,11 +21,16 @@ export async function* list(): AsyncGenerator { return; } try { - for (const [name, data] of tlpdb.parse(db)) { - if (name === 'texlive.infra' || !RE.nonPackage.test(name)) { - const version = RE.version.exec(data)?.[1]?.trimEnd(); - const revision = RE.revision.exec(data)?.[1] ?? ''; - yield { name, version, revision }; + for (const [tag, data] of tlpdb.parse(db)) { + if ( + tag === 'TLPOBJ' && match(data.name) + .with('texlive.infra', () => true) + .with(P.string.includes('.'), () => false) // platform-specific subpackage + .with(P.string.startsWith('scheme-'), () => false) + .with(P.string.startsWith('collection-'), () => false) + .otherwise(() => true) + ) { + yield data; } } } catch (error) { diff --git a/packages/main/src/texlive/tlpkg/index.ts b/packages/main/src/texlive/tlpkg/index.ts index 7a5f679..6c766b7 100644 --- a/packages/main/src/texlive/tlpkg/index.ts +++ b/packages/main/src/texlive/tlpkg/index.ts @@ -1,5 +1,5 @@ export * from '#/texlive/tlpkg/errors'; export * from '#/texlive/tlpkg/patch'; export * as tlpdb from '#/texlive/tlpkg/tlpdb'; -export type { Tlpobj } from '#/texlive/tlpkg/tlpdb'; +export type * from '#/texlive/tlpkg/tlpdb'; export * from '#/texlive/tlpkg/util'; diff --git a/packages/main/src/texlive/tlpkg/tlpdb.ts b/packages/main/src/texlive/tlpkg/tlpdb.ts index 53b773a..83ba3b1 100644 --- a/packages/main/src/texlive/tlpkg/tlpdb.ts +++ b/packages/main/src/texlive/tlpkg/tlpdb.ts @@ -1,10 +1,66 @@ -export interface Tlpobj { - readonly name: string; - readonly version: string | undefined; - readonly revision: string; +import type { TLPDBSINGLE, TLPOBJ } from 'texlive-json-schemas/types'; + +export interface TLPObj { + name: TLPOBJ['name']; + revision: string; + cataloguedata?: { + version?: NonNullable['version']; + }; +} + +export interface TLConfig + extends Pick, 'release'> +{} + +export interface TLOptions { + location?: string; +} + +const TAG = { + TLPOBJ: 'TLPOBJ', + TLConfig: 'TLConfig', + TLOptions: 'TLOptions', +} as const; + +export type Entry = + | [typeof TAG.TLPOBJ, TLPObj] + | [typeof TAG.TLConfig, TLConfig] + | [typeof TAG.TLOptions, TLOptions]; + +const RE = { + version: /^catalogue-version\s+(\S.*)$/mv, + revision: /^revision\s+(\d+)\s*$/mv, + location: /^depend\s+(?:opt_)?location:(.+)$/mv, + release: /^depend\s+release\/(.+)$/mv, +} as const; + +export function* parse(db: string): Generator { + for (const [name, data] of entries(db)) { + if ( + name === '00texlive-installation.config' + || name === '00texlive.installation' + ) { + if (name === '00texlive-installation.config') { + yield [TAG.TLConfig, { release: '2008' }]; + } + const location = RE.location.exec(data)?.[1]; + if (location !== undefined) { + yield [TAG.TLOptions, { location }]; + } + } else if (name === '00texlive.config') { + const release = RE.release.exec(data)?.[1]; + if (release !== undefined) { + yield [TAG.TLConfig, { release }]; + } + } else if (!name.startsWith('00texlive')) { + const version = RE.version.exec(data)?.[1]?.trimEnd(); + const revision = RE.revision.exec(data)?.[1] ?? ''; + yield [TAG.TLPOBJ, { name, revision, cataloguedata: { version } }]; + } + } } -export function* parse( +function* entries( db: string, ): Generator<[name: string, data: string], void, void> { // dprint-ignore diff --git a/packages/main/src/util/custom-inspect.ts b/packages/main/src/util/custom-inspect.ts index 97f3c27..e3ea1cb 100644 --- a/packages/main/src/util/custom-inspect.ts +++ b/packages/main/src/util/custom-inspect.ts @@ -1,7 +1,11 @@ import { EOL, platform } from 'node:os'; import * as path from 'node:path'; import { env } from 'node:process'; -import type { InspectOptions, InspectOptionsStylized } from 'node:util'; +import { + type InspectOptions, + type InspectOptionsStylized, + inspect as utilInspect, +} from 'node:util'; import { isDebug } from '@actions/core'; import ansi from 'ansi-styles'; @@ -18,7 +22,7 @@ Reflect.defineProperty(Error.prototype, customInspect, { this: Readonly, depth: number, options: Readonly, - inspect: Inspect, + inspect: Inspect = utilInspect, ): string { if (depth < 0) { return `[${getErrorName(this)}]`; @@ -33,7 +37,7 @@ Reflect.defineProperty(Error.prototype, customInspect, { function formatError( error: Readonly, options: Readonly, - inspect: Inspect, + inspect: Inspect = utilInspect, ): string { let stylized: string = inspectNoCustom(error, options, inspect); // Colorize error name in red to improve legibility. @@ -56,7 +60,7 @@ function getErrorName(error: Readonly): string { function inspectNoCustom( target: object, options: Readonly, - inspect: Inspect, + inspect: Inspect = utilInspect, ): string { // Temporarily overrides `customInspect` to prevent circular calls. const success = Reflect.defineProperty(target, customInspect, { diff --git a/packages/main/tests/__mocks__/setup-texlive-action/texlive/releases.ts b/packages/main/tests/__mocks__/setup-texlive-action/texlive/releases.ts index 5e603c2..27eb485 100644 --- a/packages/main/tests/__mocks__/setup-texlive-action/texlive/releases.ts +++ b/packages/main/tests/__mocks__/setup-texlive-action/texlive/releases.ts @@ -1,16 +1,12 @@ import { vi } from 'vitest'; export namespace ReleaseData { + const latestVersionNumber = Number.parseInt(LATEST_VERSION, 10); const data = { newVersionReleased: vi.fn().mockReturnValue(false), + previous: { version: `${latestVersionNumber - 1}` }, latest: { version: LATEST_VERSION }, - isLatest: vi.fn((version: typeof LATEST_VERSION) => { - return version === LATEST_VERSION; - }), - isOnePrevious: vi.fn((version: typeof LATEST_VERSION) => { - return Number.parseInt(version, 10) + 1 - === Number.parseInt(LATEST_VERSION, 10); - }), + next: { version: `${latestVersionNumber + 1}` }, }; export const setup = vi.fn().mockResolvedValue(data); export const use = vi.fn().mockReturnValue(data); diff --git a/packages/main/tests/__tests__/action/config.test.ts b/packages/main/tests/__tests__/action/config.test.ts index 0af4dcc..3632a21 100644 --- a/packages/main/tests/__tests__/action/config.test.ts +++ b/packages/main/tests/__tests__/action/config.test.ts @@ -183,6 +183,14 @@ describe('version', () => { await expect(Config.load()).resolves.toHaveProperty('version', '2018'); }); + it('permits the next version', async () => { + vi.mocked(Inputs.load).mockReturnValueOnce({ + ...defaultInputs, + version: `${Number.parseInt(LATEST_VERSION, 10) + 1}`, + }); + await expect(Config.load()).resolves.not.toThrow(); + }); + describe.each(['linux', 'win32'] as const)('on %s', (os) => { beforeEach(() => { vi.mocked(platform).mockReturnValue(os); @@ -205,7 +213,7 @@ describe('version', () => { ...defaultInputs, version: spec, }); - await expect(Config.load()).toReject(); + await expect(Config.load()).rejects.toThrow(''); }); }); diff --git a/packages/main/tests/__tests__/action/run/main.test.ts b/packages/main/tests/__tests__/action/run/main/index.test.ts similarity index 68% rename from packages/main/tests/__tests__/action/run/main.test.ts rename to packages/main/tests/__tests__/action/run/main/index.test.ts index 60840e0..8149407 100644 --- a/packages/main/tests/__tests__/action/run/main.test.ts +++ b/packages/main/tests/__tests__/action/run/main/index.test.ts @@ -3,19 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { env } from 'node:process'; import { setOutput } from '@actions/core'; -import type { DeepWritable } from 'ts-essentials'; +import type { Writable } from 'ts-essentials'; import { CacheService } from '#/action/cache'; import { Config } from '#/action/config'; import { main } from '#/action/run/main'; -import { ReleaseData, installTL } from '#/texlive'; +import { install } from '#/action/run/main/install'; +import { adjustTexmf, updateTlmgr } from '#/action/run/main/update'; import * as tlmgr from '#/texlive/tlmgr/actions'; vi.unmock('#/action/run/main'); -const config = {} as DeepWritable; +const config = {} as Writable; vi.mocked(Config.load).mockResolvedValue(config); +vi.mock('#/action/run/main/install'); +vi.mock('#/action/run/main/update'); + beforeEach(() => { config.cache = true; config.packages = new Set(); @@ -69,14 +73,14 @@ const setCacheType = ([type]: typeof cacheTypes[number]) => { }; it('installs TeX Live if cache not found', async () => { - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(MockCacheService.prototype.restore).toHaveBeenCalled(); - expect(installTL).toHaveBeenCalled(); + expect(install).toHaveBeenCalled(); }); it('does not use cache if input cache is false', async () => { config.cache = false; - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(MockCacheService.prototype.restore).not.toHaveBeenCalled(); }); @@ -84,8 +88,8 @@ it.each(cacheTypes)( 'does not install TeX Live if cache found (%s)', async (...kind) => { setCacheType(kind); - await expect(main()).toResolve(); - expect(installTL).not.toHaveBeenCalled(); + await expect(main()).resolves.not.toThrow(); + expect(install).not.toHaveBeenCalled(); }, ); @@ -93,21 +97,21 @@ it.each([LATEST_VERSION, '2009', '2014'] as const)( 'sets version to %o with input %o', async (version) => { config.version = version; - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(setOutput).toHaveBeenCalledWith('version', version); }, ); it('adds TeX Live to path after installation', async () => { - await expect(main()).toResolve(); - expect(tlmgr.path.add).toHaveBeenCalledAfter(installTL); + await expect(main()).resolves.not.toThrow(); + expect(tlmgr.path.add).toHaveBeenCalledAfter(install); }); it.each(cacheTypes)( 'adds TeX Live to path after cache restoration (%s)', async (...kind) => { setCacheType(kind); - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(tlmgr.path.add).not.toHaveBeenCalledBefore( MockCacheService.prototype.restore, ); @@ -119,18 +123,17 @@ it.each([[true], [false]])( 'does not update any TeX packages for new installation', async (input) => { config.updateAllPackages = input; - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(tlmgr.update).not.toHaveBeenCalled(); }, ); it.each(cacheTypes)( - 'updates `tlmgr` when cache restored (%s)', + 'updates TeX Live when cache restored (%s)', async (...kind) => { setCacheType(kind); - await expect(main()).toResolve(); - expect(tlmgr.update).toHaveBeenCalledOnce(); - expect(tlmgr.update).toHaveBeenCalledWith({ self: true }); + await expect(main()).resolves.not.toThrow(); + expect(updateTlmgr).toHaveBeenCalledOnce(); }, ); @@ -139,8 +142,7 @@ it.each(cacheTypes)( async (...kind) => { setCacheType(kind); config.updateAllPackages = true; - await expect(main()).toResolve(); - expect(tlmgr.update).toHaveBeenCalledTimes(2); + await expect(main()).resolves.not.toThrow(); expect(tlmgr.update).toHaveBeenCalledWith({ all: true, reinstallForciblyRemoved: true, @@ -148,33 +150,17 @@ it.each(cacheTypes)( }, ); -it.each(cacheTypes)( - 'updates tlmgr for the one previous version', - async (...kind) => { - setCacheType(kind); - config.updateAllPackages = true; - config.version = `${ - Number.parseInt(LATEST_VERSION, 10) - 1 - }` as typeof config.version; - vi.mocked(ReleaseData.use().newVersionReleased).mockReturnValue(true); - await expect(main()).resolves.not.toThrow(); - expect(tlmgr.update).toHaveBeenCalledWith( - expect.objectContaining({ self: true }), - ); - }, -); - it('does nothing about TEXMF for new installation', async () => { - await expect(main()).toResolve(); - expect(tlmgr.conf.texmf).not.toHaveBeenCalled(); + await expect(main()).resolves.not.toThrow(); + expect(adjustTexmf).not.toHaveBeenCalled(); }); it.each(cacheTypes)( 'may change TEXMF after adding TeX Live to path (%s)', async (...kind) => { setCacheType(kind); - await expect(main()).toResolve(); - expect(tlmgr.conf.texmf).not.toHaveBeenCalledBefore(tlmgr.path.add); + await expect(main()).resolves.not.toThrow(); + expect(adjustTexmf).not.toHaveBeenCalledBefore(tlmgr.path.add); }, ); @@ -182,41 +168,20 @@ it.each(cacheTypes)( 'change old settings if they are not appropriate (%s)', async (...kind) => { setCacheType(kind); - vi.mocked(tlmgr.conf.texmf).mockResolvedValue('' as unknown as void); - await expect(main()).toResolve(); - expect(tlmgr.conf.texmf).toHaveBeenCalledWith( - 'TEXMFHOME', - expect.anything(), - ); - vi.mocked(tlmgr.conf.texmf).mockReset(); - }, -); - -it.each(cacheTypes)( - 'does not change old settings if not necessary (case %s)', - async (...kind) => { - setCacheType(kind); - vi.mocked(tlmgr.conf.texmf).mockResolvedValue( - '/texmf-local' as unknown as void, - ); - await expect(main()).toResolve(); - expect(tlmgr.conf.texmf).not.toHaveBeenCalledWith( - 'TEXMFHOME', - expect.anything(), - ); - vi.mocked(tlmgr.conf.texmf).mockReset(); + await expect(main()).resolves.not.toThrow(); + expect(adjustTexmf).toHaveBeenCalled(); }, ); it('does not setup tlcontrib by default', async () => { - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(tlmgr.repository.add).not.toHaveBeenCalled(); expect(tlmgr.pinning.add).not.toHaveBeenCalled(); }); it('sets up tlcontrib if input tlcontrib is true', async () => { config.tlcontrib = true; - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(tlmgr.repository.add).not.toHaveBeenCalledBefore(tlmgr.path.add); expect(tlmgr.repository.add).toHaveBeenCalledWith( expect.anything(), @@ -232,7 +197,7 @@ describe.each([[true], [false]])('%j', (force) => { }); it('does not install any packages by default', async () => { - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(tlmgr.install).not.toHaveBeenCalled(); }); @@ -241,7 +206,7 @@ describe.each([[true], [false]])('%j', (force) => { async (...kind) => { setCacheType(kind); config.packages = new Set(['foo', 'bar', 'baz']); - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(tlmgr.install).not.toHaveBeenCalled(); }, ); @@ -251,7 +216,7 @@ describe.each([[true], [false]])('%j', (force) => { async (...kind) => { setCacheType(kind); config.packages = new Set(['foo', 'bar', 'baz']); - await expect(main()).toResolve(); + await expect(main()).resolves.not.toThrow(); expect(tlmgr.install).toHaveBeenCalled(); }, ); diff --git a/packages/main/tests/__tests__/action/run/main/install.test.ts b/packages/main/tests/__tests__/action/run/main/install.test.ts new file mode 100644 index 0000000..3d85c40 --- /dev/null +++ b/packages/main/tests/__tests__/action/run/main/install.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { install } from '#/action/run/main/install'; +import { + type InstallTL, + InstallTLError, + Profile, + TlpdbError, + acquire, +} from '#/texlive'; + +vi.mocked(acquire).mockResolvedValue({ run: vi.fn() } as unknown as InstallTL); + +const downloadErrors = [ + new InstallTLError('', { code: InstallTLError.Code.FAILED_TO_DOWNLOAD }), + new InstallTLError('', { code: InstallTLError.Code.UNEXPECTED_VERSION }), +] as const; + +const installErrors = [ + new InstallTLError('', { + code: InstallTLError.Code.INCOMPATIBLE_REPOSITORY_VERSION, + }), + new TlpdbError('', { code: TlpdbError.Code.FAILED_TO_INITIALIZE }), +] as const; + +const errors = [...downloadErrors, ...installErrors] as const; + +describe('fallback to master', () => { + it.each(downloadErrors)('if failed to download', async (error) => { + vi.mocked(acquire).mockRejectedValueOnce(error); + const profile = new Profile(LATEST_VERSION, { prefix: '' }); + await expect(install({ profile })).resolves.not.toThrow(); + }); + + it.each(installErrors)('if failed to install', async (error) => { + vi.mocked(acquire).mockResolvedValueOnce({ + run: vi.fn().mockRejectedValueOnce(error), + } as unknown as InstallTL); + const profile = new Profile(LATEST_VERSION, { prefix: '' }); + await expect(install({ profile })).resolves.not.toThrow(); + }); +}); + +it.each(errors)('does not fallback for older versions', async (error) => { + vi.mocked(acquire).mockRejectedValueOnce(error); + const profile = new Profile('2021', { prefix: '' }); + await expect(install({ profile })).rejects.toThrow(error); +}); + +it.each(errors)('does not fallback if repository set', async (error) => { + vi.mocked(acquire).mockRejectedValueOnce(error); + const profile = new Profile(LATEST_VERSION, { prefix: '' }); + const repository = new URL(MOCK_URL); + await expect(install({ profile, repository })).rejects.toThrow(error); +}); diff --git a/packages/main/tests/__tests__/action/run/main/update.test.ts b/packages/main/tests/__tests__/action/run/main/update.test.ts new file mode 100644 index 0000000..82b9fcb --- /dev/null +++ b/packages/main/tests/__tests__/action/run/main/update.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, expect, it, vi } from 'vitest'; + +import { CacheService } from '#/action/cache'; +import { updateTlmgr } from '#/action/run/main/update'; +import { Tlmgr, TlmgrError, TlpdbError } from '#/texlive'; +import { list, remove } from '#/texlive/tlmgr/actions/repository'; +import { update } from '#/texlive/tlmgr/actions/update'; + +const updateCache = vi.fn(); + +vi.mocked(list).mockImplementation(async function*() {}); +vi.mocked(CacheService.use).mockReturnValue( + { update: updateCache } as unknown as CacheService, +); + +beforeEach(() => { + Tlmgr.setup({ version: LATEST_VERSION, TEXDIR: '' }); +}); + +const versionOutdated = new TlmgrError('', { + action: 'update', + code: TlmgrError.Code.TL_VERSION_OUTDATED, +}); + +const failedToInitialize = new TlpdbError('', { + code: TlpdbError.Code.FAILED_TO_INITIALIZE, +}); + +it('move to historic', async () => { + vi.mocked(update).mockRejectedValueOnce(versionOutdated); + const opts = { version: LATEST_VERSION }; + await expect(updateTlmgr(opts)).resolves.not.toThrow(); + expect(updateCache).toHaveBeenCalled(); +}); + +it('move to historic master', async () => { + vi + .mocked(update) + .mockRejectedValueOnce(versionOutdated) + .mockRejectedValueOnce(failedToInitialize); + const opts = { version: LATEST_VERSION }; + await expect(updateTlmgr(opts)).resolves.not.toThrow(); + expect(updateCache).toHaveBeenCalled(); +}); + +it('does not move to historic if repository set', async () => { + vi.mocked(update).mockRejectedValueOnce(versionOutdated); + const opts = { version: LATEST_VERSION, repository: new URL(MOCK_URL) }; + await expect(updateTlmgr(opts)).rejects.toThrow(versionOutdated); +}); + +it('removes tlcontrib', async () => { + vi.mocked(list).mockImplementationOnce(async function*() { + yield { tag: 'main', path: MOCK_URL }; + yield { tag: 'tlcontrib', path: MOCK_URL }; + }); + const opts = { version: '2022' } as const; + await expect(updateTlmgr(opts)).resolves.not.toThrow(); + expect(remove).toHaveBeenCalledWith('tlcontrib'); +}); + +it('removes tlpretest', async () => { + vi.mocked(list).mockImplementationOnce(async function*() { + yield { tag: 'main', path: 'https://example.com/path/to/tlpretest/' }; + yield { tag: 'tlcontrib', path: MOCK_URL }; + }); + const opts = { version: LATEST_VERSION }; + await expect(updateTlmgr(opts)).resolves.not.toThrow(); + expect(remove).toHaveBeenCalledWith('main'); +}); diff --git a/packages/main/tests/__tests__/texlive/tlmgr/list.test.ts b/packages/main/tests/__tests__/texlive/tlmgr/list.test.ts index d77c9c6..d640189 100644 --- a/packages/main/tests/__tests__/texlive/tlmgr/list.test.ts +++ b/packages/main/tests/__tests__/texlive/tlmgr/list.test.ts @@ -33,15 +33,15 @@ it('lists texlive.infra', () => { expect(tlpdb['2008']).toContainEqual( expect.objectContaining({ name: 'texlive.infra', - version: undefined, revision: '12186', + cataloguedata: { version: undefined }, }), ); expect(tlpdb['2023']).toContainEqual( expect.objectContaining({ name: 'texlive.infra', - version: undefined, revision: '66822', + cataloguedata: { version: undefined }, }), ); }); @@ -80,15 +80,15 @@ it('lists normal packages', () => { expect(tlpdb['2008']).toContainEqual( expect.objectContaining({ name: 'pdftex', - version: '1.40.9', revision: '12898', + cataloguedata: { version: '1.40.9' }, }), ); expect(tlpdb['2023']).toContainEqual( expect.objectContaining({ name: 'hyphen-base', - version: undefined, revision: '66413', + cataloguedata: { version: undefined }, }), ); }); diff --git a/packages/main/tests/__tests__/texlive/tlpdb.test.ts b/packages/main/tests/__tests__/texlive/tlpdb.test.ts new file mode 100644 index 0000000..c721010 --- /dev/null +++ b/packages/main/tests/__tests__/texlive/tlpdb.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest'; + +import tlpdb2008 from '@setup-texlive-action/fixtures/texlive.2008.tlpdb'; +import tlpdb2023 from '@setup-texlive-action/fixtures/texlive.2023.tlpdb'; + +import { parse } from '#/texlive/tlpkg/tlpdb'; + +const getLocation = (db: string): string | undefined => { + for (const [tag, options] of parse(db)) { + if (tag === 'TLOptions') { + return options.location; + } + } + return undefined; +}; + +const getVersion = (db: string): string | undefined => { + for (const [tag, config] of parse(db)) { + if (tag === 'TLConfig') { + return config.release; + } + } + return undefined; +}; + +describe('2008', () => { + test('location', () => { + expect(getLocation(tlpdb2008)).toMatchInlineSnapshot( + `"http://ftp.math.utah.edu/pub/tex/historic/systems/texlive/2008/tlnet"`, + ); + }); + + test('version', () => { + expect(getVersion(tlpdb2008)).toBe('2008'); + }); +}); + +describe('2023', () => { + test('location', () => { + expect(getLocation(tlpdb2023)).toMatchInlineSnapshot( + `"http://ftp.dante.de/tex-archive/systems/texlive/tlnet"`, + ); + }); + + test('version', () => { + expect(getVersion(tlpdb2023)).toBe('2023'); + }); +});