diff --git a/README.md b/README.md index 9731292..9e4d6d3 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ There are three kinds of version labels you can use. ### Outputs - `cache-hit` - A boolean value to indicate a cache was found for the packages requested. -- `package-version-list` - The main requested packages and versions that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. \=,\=\,...). -- `all-package-version-list` - All the pulled in packages and versions, including dependencies, that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. \=,\=\,...). +- `package-version-list` - The main requested packages and versions that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. \=\,\=\,...). Packages installed for a non-native architecture include it (i.e. \:\=\). +- `all-package-version-list` - All the pulled in packages and versions, including dependencies, that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. \=\,\=\,...). Packages installed for a non-native architecture include it (i.e. \:\=\). ### Security Compliance diff --git a/action.yml b/action.yml index 5da11b0..0d120eb 100644 --- a/action.yml +++ b/action.yml @@ -37,9 +37,9 @@ outputs: cache-hit: description: "A boolean value to indicate a cache was found for the packages requested." package-version-list: - description: "The main requested packages and versions that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. ::)." + description: "The main requested packages and versions that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. =,=,...). Packages installed for a non-native architecture include it (i.e. :=)." all-package-version-list: - description: "All the pulled in packages and versions, including dependencies, that are installed. Represented as a comma delimited list with equals delimit on the package version (i.e. ::)." + description: "All the pulled in packages and versions, including dependencies, that are installed. Represented as a sorted comma delimited list with equals delimit on the package version (i.e. =,=,...). Packages installed for a non-native architecture include it (i.e. :=)." runs: using: "node24" diff --git a/src/action.ts b/src/action.ts index c9a98a2..3f08c8c 100644 --- a/src/action.ts +++ b/src/action.ts @@ -45,14 +45,31 @@ export class ActionRunner { } /** - * Converts manifest entries into the action output CSV format. + * Converts package names into the action output CSV format. * - * @param manifest Parsed manifest. - * @returns Comma-delimited name=version list. + * Arch is shown only for foreign-arch packages (e.g. `libc6:i386=2.39`), so + * native output matches the v1 format while multi-arch installs stay + * unambiguous. Entries are sorted with `PackageName.compareTo` so the output + * is stable across install order. + * + * @param packageNames Installed package names. + * @param nativeArch Runner architecture as reported by dpkg (e.g. amd64). + * @returns Comma-delimited name[:arch]=version list. */ - private toCsv(manifest: Manifest): string { - return manifest.entries - .map((entry) => entry.packageName.serialize()) + private toCsv( + packageNames: readonly PackageName[], + nativeArch: string, + ): string { + return [...packageNames] + .sort((a, b) => a.compareTo(b)) + .map((name) => { + const foreignArch = + name.arch !== undefined && name.arch !== nativeArch + ? `:${name.arch}` + : ""; + const version = name.version === undefined ? "" : `=${name.version}`; + return `${name.name}${foreignArch}${version}`; + }) .join(","); } @@ -77,12 +94,14 @@ export class ActionRunner { ); const cacheHit = manifest !== undefined; + // Needed on a cache hit too, to resolve the native arch for outputs. + const installManager = await createPackageManager( + true, + this.logger, + this.logger, + ); + if (!cacheHit) { - const installManager = await createPackageManager( - true, - this.logger, - this.logger, - ); const packageInfos = await installManager.install(packageNames.toArray()); // Collect each package's installed files so the archives have content. const entries = await Promise.all( @@ -98,10 +117,15 @@ export class ActionRunner { await this.cache.archiveAndSave(manifest); } + const nativeArch = await installManager.getNativeArchitecture(); + const installed = manifest!.entries.map((entry) => entry.packageName); return { cacheHit, - packageVersionList: this.toCsv(manifest!), - allPackageVersionList: this.toCsv(manifest!), + packageVersionList: this.toCsv( + installed.filter((name) => packageNames.includes(name)), + nativeArch, + ), + allPackageVersionList: this.toCsv(installed, nativeArch), }; } } diff --git a/src/packages.ts b/src/packages.ts index 4730c25..d197d2f 100644 --- a/src/packages.ts +++ b/src/packages.ts @@ -171,6 +171,25 @@ export class ActionPackageNames { toArray(): readonly PackageName[] { return this.items; } + + /** + * Checks whether an installed package was one of the requested packages. + * + * Matches on name, and on arch only when both sides specify one. Version is + * ignored because requests usually omit it and apt resolves the installed one. + * + * @param installed Package name as reported by apt after install. + * @returns True when the package was requested. + */ + includes(installed: PackageName): boolean { + return this.items.some( + (requested) => + requested.name === installed.name && + (requested.arch === undefined || + installed.arch === undefined || + requested.arch === installed.arch), + ); + } } /** diff --git a/test/action-runner.test.ts b/test/action-runner.test.ts index d359dad..ee1d0ef 100644 --- a/test/action-runner.test.ts +++ b/test/action-runner.test.ts @@ -15,6 +15,8 @@ vi.mock("ts-apt", async (importOriginal) => { }; }); +const NATIVE_ARCH = "amd64"; + describe("ActionRunner", () => { afterEach(() => { vi.restoreAllMocks(); @@ -45,6 +47,9 @@ describe("ActionRunner", () => { const cacheKey = new CacheKey("v1", "0", process.arch, packageNames); const restored = new Manifest(new Date(), [], cacheKey); cache.loadAndRestore.mockResolvedValue(restored); + vi.mocked(createPackageManager).mockResolvedValue({ + getNativeArchitecture: vi.fn().mockResolvedValue(NATIVE_ARCH), + } as never); const result = await runner.runAction({ packages: "curl git", @@ -68,6 +73,7 @@ describe("ActionRunner", () => { vi.mocked(createPackageManager).mockResolvedValue({ install: vi.fn().mockResolvedValue([{ name: installedPackage }]), listInstalledFiles: vi.fn().mockResolvedValue([installedFile]), + getNativeArchitecture: vi.fn().mockResolvedValue(NATIVE_ARCH), } as never); await runner.runAction({ diff --git a/test/action.test.ts b/test/action.test.ts index 7524fc5..0f55ed5 100644 --- a/test/action.test.ts +++ b/test/action.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { runAction, type ActionInputs, @@ -9,6 +9,19 @@ import { CacheKey } from "../src/cache.js"; import { createPackageName } from "ts-apt"; import { ActionPackageNames } from "../src/packages.js"; +const NATIVE_ARCH = "amd64"; +const FOREIGN_ARCH = "i386"; + +vi.mock("ts-apt", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPackageManager: async () => ({ + getNativeArchitecture: async () => NATIVE_ARCH, + }), + }; +}); + describe("runAction", () => { const PKG_NAME = "curl"; const PKG_VER = "1.2.3"; @@ -71,4 +84,45 @@ describe("runAction", () => { `${PKG_NAME}=${PKG_VER},${PKG2_NAME}=${PKG_VER},${PKG3_NAME}=${PKG_VER}`, ); }); + + it("runAction cache hit with dependencies lists requested and all packages sorted with foreign arch only", async () => { + const inputs: ActionInputs = { + packages: `${PKG2_NAME} ${PKG_NAME}`, + version: PKG_VER, + executeInstallScripts: false, + emptyPackagesBehavior: "error", + debug: false, + }; + const cacheKey = new CacheKey( + inputs.version, + "0", + process.arch, + ActionPackageNames.fromInput(inputs.packages), + ); + // Install order, with a native-arch dependency and a foreign-arch request. + const manifest = new Manifest( + new Date(), + [ + new ManifestEntry(createPackageName(PKG2_NAME, PKG_VER, FOREIGN_ARCH)), + new ManifestEntry(createPackageName(PKG3_NAME, PKG_VER, NATIVE_ARCH)), + new ManifestEntry(createPackageName(PKG_NAME, PKG_VER, undefined)), + ], + cacheKey, + ); + const mockCache = { loadAndRestore: async () => manifest }; + + const outputs = await runAction( + inputs, + {} as never, + mockCache as never, + {} as never, + ); + + expect(outputs.packageVersionList).toBe( + `${PKG_NAME}=${PKG_VER},${PKG2_NAME}:${FOREIGN_ARCH}=${PKG_VER}`, + ); + expect(outputs.allPackageVersionList).toBe( + `${PKG_NAME}=${PKG_VER},${PKG2_NAME}:${FOREIGN_ARCH}=${PKG_VER},${PKG3_NAME}=${PKG_VER}`, + ); + }); }); diff --git a/test/packages.test.ts b/test/packages.test.ts index 168594f..f7bde72 100644 --- a/test/packages.test.ts +++ b/test/packages.test.ts @@ -170,5 +170,18 @@ describe("packages", () => { createPackageName("test-package", "1.0.0", "amd64"), ]); }); + + it.each([ + ["same name without arch returns true", "xdot", createPackageName("xdot", "1.3-1"), true], + ["requested without arch returns true", "xdot", createPackageName("xdot", "1.3-1", "amd64"), true], + ["installed without arch returns true", "xdot:amd64", createPackageName("xdot", "1.3-1"), true], + ["matching arch returns true", "xdot:amd64", createPackageName("xdot", "1.3-1", "amd64"), true], + ["different arch returns false", "xdot:i386", createPackageName("xdot", "1.3-1", "amd64"), false], + ["different name returns false", "xdot", createPackageName("rolldice", "1.16"), false], + ])("includes %s", (_case, input, installed, expected) => { + expect(ActionPackageNames.fromInput(input).includes(installed)).toBe( + expected, + ); + }); }); });