From f617f8f3512983df02293afa3a342815676e6db9 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Mon, 15 Jun 2026 16:37:32 -0400 Subject: [PATCH 01/11] refactor: move shareable methods into tools file --- dist/rockcraft-pack-action/index.js | 40 +++++------ src/rockcraft-pack.ts | 8 +-- src/tools.ts | 32 ++++----- tests/rockcraft-pack.test.ts | 108 ++++++++++++++-------------- tests/tools.test.ts | 78 ++++++++++++++++++-- 5 files changed, 160 insertions(+), 106 deletions(-) diff --git a/dist/rockcraft-pack-action/index.js b/dist/rockcraft-pack-action/index.js index 6b59c25..128a538 100644 --- a/dist/rockcraft-pack-action/index.js +++ b/dist/rockcraft-pack-action/index.js @@ -19817,7 +19817,7 @@ var exec3 = __toESM(require_exec()); var fs2 = __toESM(require("fs")); var path = __toESM(require("path")); -// src/tools.ts +// src/common/tools.ts var core = __toESM(require_core()); var exec = __toESM(require_exec()); var fs = __toESM(require("fs")); @@ -19847,24 +19847,18 @@ function validateArgument(value, field) { throw new Error(`Invalid argument '${value}' in field '${field}'`); } } -async function haveRockcraftTest() { - return await exec.exec("sudo", ["rockcraft", "test", "-h"]) === 0; -} -async function haveProFlag() { +async function haveFlag(tool, flag) { let output = ""; - await exec.exec("script", ["-q", "-c", "rockcraft pack -h"], { + await exec.exec("script", ["-q", "-c", `${tool} pack -h`], { silent: true, listeners: { stdout: (data) => output += data.toString() } }); - return output.includes("--pro"); + return output.includes(flag); } -async function haveIgnoreFlag() { - let output = ""; - await exec.exec("script", ["-q", "-c", "rockcraft pack -h"], { - silent: true, - listeners: { stdout: (data) => output += data.toString() } - }); - return output.includes("--ignore"); +async function haveSubcommand(tool, subcommand) { + return await exec.exec("sudo", [tool, subcommand, "-h"], { + ignoreReturnCode: true + }) === 0; } async function ensureSnapd() { const haveSnapd = await haveExecutable("/usr/bin/snap"); @@ -19941,16 +19935,16 @@ async function ensureLXD(configurePro) { } await ensureLXDNetwork(); } -async function ensureRockcraft(channel, revision) { - const haveRockcraft = await haveExecutable("/snap/bin/rockcraft"); - core.info("Installing Rockcraft..."); +async function ensureCraftTool(name, channel, revision) { + const haveSnap = await haveExecutable(`/snap/bin/${name}`); + core.info(`Installing ${name}...`); await exec.exec("sudo", [ "snap", - haveRockcraft ? "refresh" : "install", + haveSnap ? "refresh" : "install", revision.length > 0 ? "--revision" : "--channel", revision.length > 0 ? revision : channel, "--classic", - "rockcraft" + name ]); } @@ -19983,7 +19977,7 @@ var RockcraftBuilder = class { core2.startGroup("Installing Rockcraft plus dependencies"); await ensureSnapd(); await ensureLXD(!!this.buildPro); - await ensureRockcraft(this.rockcraftChannel, this.rockcraftRevision); + await ensureCraftTool("rockcraft", this.rockcraftChannel, this.rockcraftRevision); core2.endGroup(); const sudoArgs = ["--user", shellUser()]; let rockcraft = "rockcraft pack"; @@ -19992,7 +19986,7 @@ var RockcraftBuilder = class { const testFile = `${this.projectRoot}/spread.yaml`; if (!fileExists(testFile)) { throw new Error(`Cannot run tests. Missing ${testFile} file.`); - } else if (!await haveRockcraftTest()) { + } else if (!await haveSubcommand("rockcraft", "test")) { throw new Error( "Cannot run tests. rockcraft test is not a valid command." ); @@ -20002,7 +19996,7 @@ var RockcraftBuilder = class { } if (this.buildPro) { validateArgument(this.buildPro, "pro"); - if (!await haveProFlag()) { + if (!await haveFlag("rockcraft", "--pro")) { throw new Error( "Cannot build pro rock. This rockcraft version does not support pro." ); @@ -20015,7 +20009,7 @@ var RockcraftBuilder = class { } if (this.ignore) { validateArgument(this.ignore, "ignore"); - if (!await haveIgnoreFlag()) { + if (!await haveFlag("rockcraft", "--ignore")) { throw new Error("This rockcraft version does not support ignore."); } rockcraftPackArgs = `${rockcraftPackArgs} --ignore=${this.ignore}`; diff --git a/src/rockcraft-pack.ts b/src/rockcraft-pack.ts index 119ab0e..907f200 100644 --- a/src/rockcraft-pack.ts +++ b/src/rockcraft-pack.ts @@ -49,7 +49,7 @@ export class RockcraftBuilder { core.startGroup('Installing Rockcraft plus dependencies') await tools.ensureSnapd() await tools.ensureLXD(!!this.buildPro) - await tools.ensureRockcraft(this.rockcraftChannel, this.rockcraftRevision) + await tools.ensureCraftTool('rockcraft', this.rockcraftChannel, this.rockcraftRevision) core.endGroup() const sudoArgs = ['--user', tools.shellUser()] @@ -61,7 +61,7 @@ export class RockcraftBuilder { if (!tools.fileExists(testFile)) { throw new Error(`Cannot run tests. Missing ${testFile} file.`) - } else if (!(await tools.haveRockcraftTest())) { + } else if (!(await tools.haveSubcommand('rockcraft', 'test'))) { throw new Error( 'Cannot run tests. rockcraft test is not a valid command.' ) @@ -72,7 +72,7 @@ export class RockcraftBuilder { if (this.buildPro) { tools.validateArgument(this.buildPro, 'pro') - if (!(await tools.haveProFlag())) { + if (!(await tools.haveFlag('rockcraft', '--pro'))) { throw new Error( 'Cannot build pro rock. This rockcraft version does not support pro.' ) @@ -87,7 +87,7 @@ export class RockcraftBuilder { if (this.ignore) { tools.validateArgument(this.ignore, 'ignore') - if (!(await tools.haveIgnoreFlag())) { + if (!(await tools.haveFlag('rockcraft', '--ignore'))) { throw new Error('This rockcraft version does not support ignore.') } rockcraftPackArgs = `${rockcraftPackArgs} --ignore=${this.ignore}` diff --git a/src/tools.ts b/src/tools.ts index 676b460..09ee471 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -35,26 +35,19 @@ export function validateArgument(value: string, field: string): void { } } -export async function haveRockcraftTest(): Promise { - return (await exec.exec('sudo', ['rockcraft', 'test', '-h'])) === 0 -} - -export async function haveProFlag(): Promise { +export async function haveFlag(tool: string, flag: string): Promise { let output = '' - await exec.exec('script', ['-q', '-c', 'rockcraft pack -h'], { + await exec.exec('script', ['-q', '-c', `${tool} pack -h`], { silent: true, listeners: {stdout: data => (output += data.toString())} }) - return output.includes('--pro') + return output.includes(flag) } -export async function haveIgnoreFlag(): Promise { - let output = '' - await exec.exec('script', ['-q', '-c', 'rockcraft pack -h'], { - silent: true, - listeners: {stdout: data => (output += data.toString())} - }) - return output.includes('--ignore') +export async function haveSubcommand(tool: string, subcommand: string): Promise { + return ( + (await exec.exec(tool, [subcommand, '-h'], {ignoreReturnCode: true})) === 0 + ) } export async function ensureSnapd(): Promise { @@ -145,18 +138,19 @@ export async function ensureLXD(configurePro: boolean): Promise { await ensureLXDNetwork() } -export async function ensureRockcraft( +export async function ensureCraftTool( + name: string, channel: string, revision: string ): Promise { - const haveRockcraft = await haveExecutable('/snap/bin/rockcraft') - core.info('Installing Rockcraft...') + const haveSnap = await haveExecutable(`/snap/bin/${name}`) + core.info(`Installing ${name}...`) await exec.exec('sudo', [ 'snap', - haveRockcraft ? 'refresh' : 'install', + haveSnap ? 'refresh' : 'install', revision.length > 0 ? '--revision' : '--channel', revision.length > 0 ? revision : channel, '--classic', - 'rockcraft' + name ]) } diff --git a/tests/rockcraft-pack.test.ts b/tests/rockcraft-pack.test.ts index 13db86e..c8f2354 100644 --- a/tests/rockcraft-pack.test.ts +++ b/tests/rockcraft-pack.test.ts @@ -46,9 +46,9 @@ test('RockcraftBuilder.pack runs a rock build', async () => { const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) const shellUser = jest .spyOn(tools, 'shellUser') .mockImplementation((): string => user) @@ -74,7 +74,7 @@ test('RockcraftBuilder.pack runs a rock build', async () => { expect(ensureSnapd).toHaveBeenCalled() expect(ensureLXD).toHaveBeenCalled() - expect(ensureRockcraft).toHaveBeenCalled() + expect(ensureCraftTool).toHaveBeenCalled() expect(shellUser).toHaveBeenCalled() expect(execMock).toHaveBeenCalledWith( 'sudo', @@ -102,9 +102,9 @@ test('RockcraftBuilder.build can set the Rockcraft channel', async () => { const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) const execMock = jest .spyOn(exec, 'exec') .mockImplementation( @@ -124,7 +124,7 @@ test('RockcraftBuilder.build can set the Rockcraft channel', async () => { }) await builder.pack() - expect(ensureRockcraft).toHaveBeenCalledWith('test-channel', '') + expect(ensureCraftTool).toHaveBeenCalledWith('rockcraft', 'test-channel', '') }) test('RockcraftBuilder.build can set the Rockcraft revision', async () => { @@ -136,9 +136,9 @@ test('RockcraftBuilder.build can set the Rockcraft revision', async () => { const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) const execMock = jest .spyOn(exec, 'exec') .mockImplementation( @@ -158,7 +158,7 @@ test('RockcraftBuilder.build can set the Rockcraft revision', async () => { }) await builder.pack() - expect(ensureRockcraft).toHaveBeenCalledWith('channel', '123') + expect(ensureCraftTool).toHaveBeenCalledWith('rockcraft', 'channel', '123') }) test('RockcraftBuilder.build can pass known verbosity', async () => { @@ -172,9 +172,9 @@ test('RockcraftBuilder.build can pass known verbosity', async () => { const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) const shellUser = jest .spyOn(tools, 'shellUser') .mockImplementation((): string => user) @@ -236,11 +236,11 @@ test('RockcraftBuilder.build can ignore unmaintained', async () => { const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) - const haveIgnoreFlag = jest - .spyOn(tools, 'haveIgnoreFlag') + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) + const haveFlag = jest + .spyOn(tools, 'haveFlag') .mockImplementation(async (): Promise => true) const shellUser = jest .spyOn(tools, 'shellUser') @@ -293,14 +293,14 @@ test('RockcraftBuilder.pack runs a rock build and test', async () => { const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) const shellUser = jest .spyOn(tools, 'shellUser') .mockImplementation((): string => user) - const haveRockcraftTest = jest - .spyOn(tools, 'haveRockcraftTest') + const haveSubcommand = jest + .spyOn(tools, 'haveSubcommand') .mockImplementation(async (): Promise => true) const fileExists = jest .spyOn(tools, 'fileExists') @@ -327,9 +327,9 @@ test('RockcraftBuilder.pack runs a rock build and test', async () => { expect(ensureSnapd).toHaveBeenCalled() expect(ensureLXD).toHaveBeenCalled() - expect(ensureRockcraft).toHaveBeenCalled() + expect(ensureCraftTool).toHaveBeenCalled() expect(shellUser).toHaveBeenCalled() - expect(haveRockcraftTest).toHaveBeenCalled() + expect(haveSubcommand).toHaveBeenCalled() expect(fileExists).toHaveBeenCalledWith('project-root/spread.yaml') expect(execMock).toHaveBeenCalledWith( 'sudo', @@ -359,9 +359,9 @@ test('RockcraftBuilder.pack fails if test is set to true and no spread.yaml is f const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) const fileExists = jest .spyOn(tools, 'fileExists') .mockImplementation((path: string) => false) @@ -382,7 +382,7 @@ test('RockcraftBuilder.pack fails if test is set to true and no spread.yaml is f ) expect(ensureSnapd).toHaveBeenCalled() expect(ensureLXD).toHaveBeenCalled() - expect(ensureRockcraft).toHaveBeenCalled() + expect(ensureCraftTool).toHaveBeenCalled() expect(fileExists).toHaveBeenCalledWith('project-root/spread.yaml') }) @@ -397,11 +397,11 @@ test('RockcraftBuilder.pack fails if test is set to true and rockcraft test is i const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) - const haveRockcraftTest = jest - .spyOn(tools, 'haveRockcraftTest') + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) + const haveSubcommand = jest + .spyOn(tools, 'haveSubcommand') .mockImplementation(async (): Promise => false) const fileExists = jest .spyOn(tools, 'fileExists') @@ -423,8 +423,8 @@ test('RockcraftBuilder.pack fails if test is set to true and rockcraft test is i ) expect(ensureSnapd).toHaveBeenCalled() expect(ensureLXD).toHaveBeenCalled() - expect(ensureRockcraft).toHaveBeenCalled() - expect(haveRockcraftTest).toHaveBeenCalled() + expect(ensureCraftTool).toHaveBeenCalled() + expect(haveSubcommand).toHaveBeenCalled() expect(fileExists).toHaveBeenCalledWith('project-root/spread.yaml') }) @@ -485,14 +485,14 @@ test('RockcraftBuilder.build can pass pro option', async () => { const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) const shellUser = jest .spyOn(tools, 'shellUser') .mockImplementation((): string => user) - const haveProFlag = jest - .spyOn(tools, 'haveProFlag') + const haveFlag = jest + .spyOn(tools, 'haveFlag') .mockImplementation(async (): Promise => true) const execMock = jest .spyOn(exec, 'exec') @@ -540,14 +540,14 @@ test('RockcraftBuilder.build fails if pro option is not available in rockcraft', const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) const shellUser = jest .spyOn(tools, 'shellUser') .mockImplementation((): string => user) - const haveProFlag = jest - .spyOn(tools, 'haveProFlag') + const haveFlag = jest + .spyOn(tools, 'haveFlag') .mockImplementation(async (): Promise => false) const builder = new build.RockcraftBuilder({ @@ -563,7 +563,7 @@ test('RockcraftBuilder.build fails if pro option is not available in rockcraft', await expect(builder.pack()).rejects.toThrow( 'Cannot build pro rock. This rockcraft version does not support pro.' ) - expect(haveProFlag).toHaveBeenCalled() + expect(haveFlag).toHaveBeenCalled() }) test('RockcraftBuilder.build fails if pro argument is invalid', async () => { @@ -577,14 +577,14 @@ test('RockcraftBuilder.build fails if pro argument is invalid', async () => { const ensureLXD = jest .spyOn(tools, 'ensureLXD') .mockImplementation(async (): Promise => {}) - const ensureRockcraft = jest - .spyOn(tools, 'ensureRockcraft') - .mockImplementation(async (channel): Promise => {}) + const ensureCraftTool = jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}) const shellUser = jest .spyOn(tools, 'shellUser') .mockImplementation((): string => user) - const haveProFlag = jest - .spyOn(tools, 'haveProFlag') + const haveFlag = jest + .spyOn(tools, 'haveFlag') .mockImplementation(async (): Promise => true) const builder = new build.RockcraftBuilder({ diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 7ec9743..8387a28 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -339,7 +339,7 @@ test('ensureLXD still calls "lxd init" if LXD is installed', async () => { expect(execMock).toHaveBeenNthCalledWith(3, 'sudo', ['lxd', 'init', '--auto']) }) -test('ensureRockcraft installs Rockcraft if needed', async () => { +test('ensureCraftTool installs a craft tool if needed', async () => { expect.assertions(4) const accessMock = jest @@ -360,7 +360,7 @@ test('ensureRockcraft installs Rockcraft if needed', async () => { } ) - await tools.ensureRockcraft('edge', '') + await tools.ensureCraftTool('rockcraft', 'edge', '') expect(accessMock).toHaveBeenCalled() expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ @@ -372,7 +372,7 @@ test('ensureRockcraft installs Rockcraft if needed', async () => { 'rockcraft' ]) - await tools.ensureRockcraft('stable', '1234') + await tools.ensureCraftTool('snapcraft', 'stable', '1234') expect(accessMock).toHaveBeenCalled() expect(execMock).toHaveBeenNthCalledWith(2, 'sudo', [ @@ -381,11 +381,11 @@ test('ensureRockcraft installs Rockcraft if needed', async () => { '--revision', '1234', '--classic', - 'rockcraft' + 'snapcraft' ]) }) -test('ensureRockcraft refreshes if Rockcraft is installed', async () => { +test('ensureCraftTool refreshes if the tool is already installed', async () => { expect.assertions(2) const accessMock = jest @@ -406,7 +406,7 @@ test('ensureRockcraft refreshes if Rockcraft is installed', async () => { } ) - await tools.ensureRockcraft('edge', '') + await tools.ensureCraftTool('rockcraft', 'edge', '') expect(accessMock).toHaveBeenCalled() expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ @@ -530,3 +530,69 @@ test('ensureLXDNetwork sets up iptables and warns only about installed packages' 'ACCEPT' ]) }) + +test('haveFlag returns true if the flag is present in the help output', async () => { + expect.assertions(2) + + const execMock = jest + .spyOn(exec, 'exec') + .mockImplementation( + async (program: string, args?: string[], options?: any): Promise => { + options?.listeners?.stdout?.(Buffer.from('--pro\n--other-flag')) + return 0 + } + ) + + await expect(tools.haveFlag('rockcraft', '--pro')).resolves.toBe(true) + expect(execMock).toHaveBeenCalledWith( + 'script', + ['-q', '-c', 'rockcraft pack -h'], + expect.anything() + ) +}) + +test('haveFlag returns false if the flag is absent from the help output', async () => { + expect.assertions(1) + + jest + .spyOn(exec, 'exec') + .mockImplementation( + async (program: string, args?: string[], options?: any): Promise => { + options?.listeners?.stdout?.(Buffer.from('--other-flag')) + return 0 + } + ) + + await expect(tools.haveFlag('rockcraft', '--pro')).resolves.toBe(false) +}) + +test('haveSubcommand returns true if the subcommand is available', async () => { + expect.assertions(2) + + const execMock = jest + .spyOn(exec, 'exec') + .mockImplementation( + async (program: string, args?: string[]): Promise => { + return 0 + } + ) + + await expect(tools.haveSubcommand('rockcraft', 'test')).resolves.toBe(true) + expect(execMock).toHaveBeenCalledWith('rockcraft', ['test', '-h'], { + ignoreReturnCode: true + }) +}) + +test('haveSubcommand returns false if the subcommand is not available', async () => { + expect.assertions(1) + + jest + .spyOn(exec, 'exec') + .mockImplementation( + async (program: string, args?: string[]): Promise => { + return 1 + } + ) + + await expect(tools.haveSubcommand('rockcraft', 'test')).resolves.toBe(false) +}) From 46dff0485458420e73ba534db5214396aa326154 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Mon, 15 Jun 2026 17:37:26 -0400 Subject: [PATCH 02/11] refactor: make app builder classes abstract --- src/craft-builder.ts | 132 ++++++++ src/rockcraft-pack-action.ts | 16 +- src/rockcraft-pack.ts | 117 +------ src/tools.ts | 5 +- tests/craft-builder.test.ts | 300 +++++++++++++++++ tests/rockcraft-pack.test.ts | 609 ++++------------------------------- 6 files changed, 517 insertions(+), 662 deletions(-) create mode 100644 src/craft-builder.ts create mode 100644 tests/craft-builder.test.ts diff --git a/src/craft-builder.ts b/src/craft-builder.ts new file mode 100644 index 0000000..cbbee53 --- /dev/null +++ b/src/craft-builder.ts @@ -0,0 +1,132 @@ +// -*- mode: javascript; js-indent-level: 2 -*- + +import * as core from '@actions/core' +import * as exec from '@actions/exec' +import * as fs from 'fs' +import * as path from 'path' +import * as tools from './tools' + +export const allowedVerbosity = ['quiet', 'brief', 'verbose', 'debug', 'trace'] + +export interface CraftBuilderOptions { + projectRoot: string + channel: string + revision: string + verbosity: string + pro?: string + runTests?: boolean +} + +export abstract class CraftBuilder { + projectRoot: string + channel: string + revision: string + verbosity: string + pro: string + runTests: boolean + + abstract toolName: string + abstract artifactType: string + + constructor(options: CraftBuilderOptions) { + this.projectRoot = tools.expandHome(options.projectRoot) + this.channel = options.channel + this.revision = options.revision + this.pro = options.pro ?? '' + this.runTests = options.runTests ?? false + + if (!options.verbosity || allowedVerbosity.includes(options.verbosity)) { + this.verbosity = options.verbosity ?? '' + } else { + throw new Error( + `Invalid verbosity "${options.verbosity}". ` + + `Allowed values are ${allowedVerbosity.join(', ')}.` + ) + } + } + + protected async buildPackArgs(): Promise { + const args: string[] = [] + + if (this.pro) { + tools.validateArgument(this.pro, 'pro') + if (!(await tools.haveFlag(this.toolName, '--pro'))) { + throw new Error(`This ${this.toolName} version does not support --pro.`) + } + args.push(`--pro=${this.pro}`) + } + + if (this.verbosity) { + tools.validateArgument(this.verbosity, 'verbosity') + args.push('--verbosity', this.verbosity) + } + + return args + } + + protected async doPack(subcommand: 'pack' | 'test'): Promise { + const packArgs = await this.buildPackArgs() + await exec.exec( + 'sudo', + [ + '--preserve-env', + '--user', + tools.shellUser(), + this.toolName, + subcommand, + ...packArgs + ], + {cwd: this.projectRoot} + ) + } + + private async resolvePackSubcommand(): Promise<'pack' | 'test'> { + if (!this.runTests) return 'pack' + + const testFile = `${this.projectRoot}/spread.yaml` + if (!tools.fileExists(testFile)) { + throw new Error(`Cannot run tests. Missing ${testFile} file.`) + } + if (!(await tools.haveSubcommand(this.toolName, 'test'))) { + throw new Error( + `Cannot run tests. ${this.toolName} test is not a valid command.` + ) + } + return 'test' + } + + async pack(): Promise { + core.startGroup(`Installing ${this.toolName} plus dependencies`) + await tools.ensureSnapd() + await tools.ensureLXD(!!this.pro) + await tools.ensureCraftTool(this.toolName, this.channel, this.revision) + core.endGroup() + await this.doPack(await this.resolvePackSubcommand()) + } + + async outputArtifact(): Promise { + const artifacts = await this.outputArtifacts(this.artifactType) + if (artifacts.length > 1) { + core.warning( + `Multiple ${this.artifactType} files found in ${this.projectRoot}` + ) + } + return artifacts[0] + } + + async #readdir(dir: string): Promise { + return await fs.promises.readdir(dir) + } + + protected async outputArtifacts(extension: string): Promise { + const files = await this.#readdir(this.projectRoot) + const artifacts = files + .filter(name => name.endsWith(extension)) + .map(name => path.join(this.projectRoot, name)) + + if (artifacts.length === 0) { + throw new Error(`No ${extension} files produced by build`) + } + return artifacts + } +} diff --git a/src/rockcraft-pack-action.ts b/src/rockcraft-pack-action.ts index cc444d2..5ba3c51 100644 --- a/src/rockcraft-pack-action.ts +++ b/src/rockcraft-pack-action.ts @@ -7,10 +7,10 @@ async function run(): Promise { try { const projectRoot = core.getInput('path') core.info(`Building rock in "${projectRoot}"...`) - const buildPro = core.getInput('pro') || '' + const pro = core.getInput('pro') || '' const rockcraftRevision = core.getInput('revision') || '' const rockcraftChannel = core.getInput('rockcraft-channel') || 'stable' - const runRockcraftTest = core.getInput('test').toLowerCase() === 'true' + const runTests = core.getInput('test').toLowerCase() === 'true' const ignore = core.getInput('ignore') if (rockcraftRevision.length < 1) { core.warning( @@ -21,15 +21,15 @@ async function run(): Promise { const builder = new RockcraftBuilder({ projectRoot, - rockcraftChannel, - rockcraftPackVerbosity, - rockcraftRevision, - runRockcraftTest, - buildPro, + channel: rockcraftChannel, + verbosity: rockcraftPackVerbosity, + revision: rockcraftRevision, + runTests, + pro, ignore }) await builder.pack() - const rock = await builder.outputRock() + const rock = await builder.outputArtifact() core.setOutput('rock', rock) } catch (error) { core.setFailed((error as Error)?.message) diff --git a/src/rockcraft-pack.ts b/src/rockcraft-pack.ts index 907f200..74987c0 100644 --- a/src/rockcraft-pack.ts +++ b/src/rockcraft-pack.ts @@ -1,124 +1,35 @@ // -*- mode: javascript; js-indent-level: 2 -*- -import * as core from '@actions/core' -import * as exec from '@actions/exec' -import * as fs from 'fs' -import * as path from 'path' import * as tools from './tools' +import {CraftBuilder, CraftBuilderOptions} from './craft-builder' -const allowedVerbosity = ['quiet', 'brief', 'verbose', 'debug', 'trace'] - -interface RockcraftBuilderOptions { - projectRoot: string - rockcraftChannel: string - rockcraftPackVerbosity: string - rockcraftRevision: string - runRockcraftTest: boolean - buildPro: string +export interface RockcraftBuilderOptions extends CraftBuilderOptions { ignore: string } -export class RockcraftBuilder { - projectRoot: string - rockcraftChannel: string - rockcraftPackVerbosity: string - rockcraftRevision: string - runRockcraftTest: boolean - buildPro: string +export class RockcraftBuilder extends CraftBuilder { + toolName = 'rockcraft' + artifactType = '.rock' ignore: string constructor(options: RockcraftBuilderOptions) { - this.projectRoot = tools.expandHome(options.projectRoot) - this.rockcraftChannel = options.rockcraftChannel - this.rockcraftRevision = options.rockcraftRevision - this.runRockcraftTest = options.runRockcraftTest - this.buildPro = options.buildPro + super(options) this.ignore = options.ignore - - if (allowedVerbosity.includes(options.rockcraftPackVerbosity)) { - this.rockcraftPackVerbosity = options.rockcraftPackVerbosity - } else { - throw new Error( - 'Invalid verbosity "${options.rockcraftPackVerbosity}".' + - 'Allowed values are ${allowedVerbosity.join(", ")}.' - ) - } } - async pack(): Promise { - core.startGroup('Installing Rockcraft plus dependencies') - await tools.ensureSnapd() - await tools.ensureLXD(!!this.buildPro) - await tools.ensureCraftTool('rockcraft', this.rockcraftChannel, this.rockcraftRevision) - core.endGroup() - - const sudoArgs = ['--user', tools.shellUser()] - let rockcraft = 'rockcraft pack' - let rockcraftPackArgs = '' - - if (this.runRockcraftTest) { - const testFile = `${this.projectRoot}/spread.yaml` - - if (!tools.fileExists(testFile)) { - throw new Error(`Cannot run tests. Missing ${testFile} file.`) - } else if (!(await tools.haveSubcommand('rockcraft', 'test'))) { - throw new Error( - 'Cannot run tests. rockcraft test is not a valid command.' - ) - } else { - rockcraft = 'rockcraft test' - } - } - - if (this.buildPro) { - tools.validateArgument(this.buildPro, 'pro') - if (!(await tools.haveFlag('rockcraft', '--pro'))) { - throw new Error( - 'Cannot build pro rock. This rockcraft version does not support pro.' - ) - } - rockcraftPackArgs = `${rockcraftPackArgs} --pro=${this.buildPro}` - } - - if (this.rockcraftPackVerbosity) { - tools.validateArgument(this.rockcraftPackVerbosity, 'verbosity') - rockcraftPackArgs = `${rockcraftPackArgs} --verbosity ${this.rockcraftPackVerbosity}` - } + protected async buildPackArgs(): Promise { + const args = await super.buildPackArgs() if (this.ignore) { tools.validateArgument(this.ignore, 'ignore') - if (!(await tools.haveFlag('rockcraft', '--ignore'))) { - throw new Error('This rockcraft version does not support ignore.') + if (!(await tools.haveFlag(this.toolName, '--ignore'))) { + throw new Error( + `This ${this.toolName} version does not support --ignore.` + ) } - rockcraftPackArgs = `${rockcraftPackArgs} --ignore=${this.ignore}` + args.push(`--ignore=${this.ignore}`) } - rockcraft = `${rockcraft} ${rockcraftPackArgs.trim()}` - await exec.exec( - 'sudo', - ['--preserve-env', ...sudoArgs, ...rockcraft.split(' ')], - { - cwd: this.projectRoot - } - ) - } - - // This wrapper is for the benefit of the tests, due to the crazy - // typing of fs.promises.readdir() - async #readdir(dir: string): Promise { - return await fs.promises.readdir(dir) - } - - async outputRock(): Promise { - const files = await this.#readdir(this.projectRoot) - const rocks = files.filter(name => name.endsWith('.rock')) - - if (rocks.length === 0) { - throw new Error('No .rock files produced by build') - } - if (rocks.length > 1) { - core.warning(`Multiple rocks found in ${this.projectRoot}`) - } - return path.join(this.projectRoot, rocks[0]) + return args } } diff --git a/src/tools.ts b/src/tools.ts index 09ee471..7a31604 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -44,7 +44,10 @@ export async function haveFlag(tool: string, flag: string): Promise { return output.includes(flag) } -export async function haveSubcommand(tool: string, subcommand: string): Promise { +export async function haveSubcommand( + tool: string, + subcommand: string +): Promise { return ( (await exec.exec(tool, [subcommand, '-h'], {ignoreReturnCode: true})) === 0 ) diff --git a/tests/craft-builder.test.ts b/tests/craft-builder.test.ts new file mode 100644 index 0000000..a00e9d1 --- /dev/null +++ b/tests/craft-builder.test.ts @@ -0,0 +1,300 @@ +// -*- mode: javascript; js-indent-level: 2 -*- + +import * as os from 'os' +import * as path from 'path' +import * as fs from 'fs' +import * as exec from '@actions/exec' +import {CraftBuilder, CraftBuilderOptions} from '../src/craft-builder' +import * as tools from '../src/tools' + +class TestBuilder extends CraftBuilder { + toolName = 'test-tool' + artifactType = '.charm' + + // Expose the protected method so tests can call it directly. + async testOutputArtifacts(ext: string): Promise { + return this.outputArtifacts(ext) + } +} + +function makeBuilder(overrides: Partial = {}): TestBuilder { + return new TestBuilder({ + projectRoot: '.', + channel: 'stable', + verbosity: '', + revision: '', + ...overrides + }) +} + +function mockSetup(user = 'ubuntu') { + return { + ensureSnapd: jest + .spyOn(tools, 'ensureSnapd') + .mockImplementation(async (): Promise => {}), + ensureLXD: jest + .spyOn(tools, 'ensureLXD') + .mockImplementation(async (): Promise => {}), + ensureCraftTool: jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}), + shellUser: jest + .spyOn(tools, 'shellUser') + .mockImplementation((): string => user), + execMock: jest + .spyOn(exec, 'exec') + .mockImplementation( + async (program: string, args?: string[]): Promise => 0 + ) + } +} + +afterEach(() => { + jest.restoreAllMocks() +}) + +test('CraftBuilder expands tilde in project root', () => { + expect(makeBuilder({projectRoot: '~'}).projectRoot).toBe(os.homedir()) + expect(makeBuilder({projectRoot: '~/foo/bar'}).projectRoot).toBe( + path.join(os.homedir(), 'foo/bar') + ) +}) + +test('CraftBuilder throws on invalid verbosity', () => { + expect(() => makeBuilder({verbosity: 'not-valid'})).toThrow() +}) + +test('CraftBuilder allows empty verbosity', () => { + expect(() => makeBuilder({verbosity: ''})).not.toThrow() +}) + +test('CraftBuilder.pack calls ensureSnapd, ensureLXD, and ensureCraftTool', async () => { + expect.assertions(3) + + const {ensureSnapd, ensureLXD, ensureCraftTool} = mockSetup() + + await makeBuilder().pack() + + expect(ensureSnapd).toHaveBeenCalled() + expect(ensureLXD).toHaveBeenCalled() + expect(ensureCraftTool).toHaveBeenCalled() +}) + +test('CraftBuilder.pack passes channel to ensureCraftTool', async () => { + expect.assertions(1) + + const {ensureCraftTool} = mockSetup() + + await makeBuilder({channel: 'test-channel', revision: ''}).pack() + + expect(ensureCraftTool).toHaveBeenCalledWith('test-tool', 'test-channel', '') +}) + +test('CraftBuilder.pack passes revision to ensureCraftTool', async () => { + expect.assertions(1) + + const {ensureCraftTool} = mockSetup() + + await makeBuilder({revision: '42'}).pack() + + expect(ensureCraftTool).toHaveBeenCalledWith('test-tool', 'stable', '42') +}) + +test('CraftBuilder.pack calls ensureLXD without pro when pro is not set', async () => { + expect.assertions(1) + + const {ensureLXD} = mockSetup() + + await makeBuilder().pack() + + expect(ensureLXD).toHaveBeenCalledWith(false) +}) + +test('CraftBuilder.pack calls ensureLXD with pro when pro is set', async () => { + expect.assertions(1) + + const {ensureLXD} = mockSetup() + jest + .spyOn(tools, 'haveFlag') + .mockImplementation(async (): Promise => true) + + await makeBuilder({pro: 'esm-apps'}).pack() + + expect(ensureLXD).toHaveBeenCalledWith(true) +}) + +test('CraftBuilder.pack executes the correct base command', async () => { + expect.assertions(1) + + const {execMock} = mockSetup() + + await makeBuilder({projectRoot: 'my-dir'}).pack() + + expect(execMock).toHaveBeenCalledWith( + 'sudo', + ['--preserve-env', '--user', 'ubuntu', 'test-tool', 'pack'], + {cwd: 'my-dir'} + ) +}) + +test('CraftBuilder.pack executes test subcommand when runTests is true', async () => { + expect.assertions(1) + + const {execMock} = mockSetup() + jest.spyOn(tools, 'fileExists').mockReturnValue(true) + jest + .spyOn(tools, 'haveSubcommand') + .mockImplementation(async (): Promise => true) + + await makeBuilder({projectRoot: 'my-dir', runTests: true}).pack() + + expect(execMock).toHaveBeenCalledWith( + 'sudo', + ['--preserve-env', '--user', 'ubuntu', 'test-tool', 'test'], + {cwd: 'my-dir'} + ) +}) + +test('CraftBuilder.pack fails when runTests is true and no spread.yaml is found', async () => { + expect.assertions(1) + + mockSetup() + jest.spyOn(tools, 'fileExists').mockReturnValue(false) + + await expect( + makeBuilder({projectRoot: 'project-root', runTests: true}).pack() + ).rejects.toThrow('Cannot run tests. Missing project-root/spread.yaml file.') +}) + +test('CraftBuilder.pack fails when runTests is true and tool has no test subcommand', async () => { + expect.assertions(1) + + mockSetup() + jest.spyOn(tools, 'fileExists').mockReturnValue(true) + jest + .spyOn(tools, 'haveSubcommand') + .mockImplementation(async (): Promise => false) + + await expect( + makeBuilder({runTests: true}).pack() + ).rejects.toThrow('Cannot run tests. test-tool test is not a valid command.') +}) + +test('CraftBuilder.pack includes --verbosity flag when verbosity is set', async () => { + expect.assertions(1) + + const {execMock} = mockSetup() + + await makeBuilder({verbosity: 'debug'}).pack() + + expect(execMock).toHaveBeenCalledWith( + 'sudo', + expect.arrayContaining(['--verbosity', 'debug']), + expect.anything() + ) +}) + +test('CraftBuilder.pack omits --verbosity flag when verbosity is empty', async () => { + expect.assertions(1) + + const {execMock} = mockSetup() + + await makeBuilder({verbosity: ''}).pack() + + expect(execMock).toHaveBeenCalledWith( + 'sudo', + expect.not.arrayContaining(['--verbosity']), + expect.anything() + ) +}) + +test('CraftBuilder.pack includes --pro flag when pro is set', async () => { + expect.assertions(1) + + const {execMock} = mockSetup() + jest + .spyOn(tools, 'haveFlag') + .mockImplementation(async (): Promise => true) + + await makeBuilder({pro: 'esm-apps,esm-infra'}).pack() + + expect(execMock).toHaveBeenCalledWith( + 'sudo', + expect.arrayContaining(['--pro=esm-apps,esm-infra']), + expect.anything() + ) +}) + +test('CraftBuilder.pack fails when pro flag is not supported by tool', async () => { + expect.assertions(1) + + mockSetup() + jest + .spyOn(tools, 'haveFlag') + .mockImplementation(async (): Promise => false) + + await expect(makeBuilder({pro: 'fips-updates'}).pack()).rejects.toThrow( + 'This test-tool version does not support --pro.' + ) +}) + +test('CraftBuilder.pack fails when pro argument is invalid', async () => { + expect.assertions(1) + + mockSetup() + jest + .spyOn(tools, 'haveFlag') + .mockImplementation(async (): Promise => true) + + await expect( + makeBuilder({pro: 'fips-updates another-command'}).pack() + ).rejects.toThrow("Invalid argument 'fips-updates another-command' in field 'pro'") +}) + +test('CraftBuilder.outputArtifacts throws when no matching files are found', async () => { + expect.assertions(1) + + jest + .spyOn(fs.promises, 'readdir') + .mockResolvedValue(['other-file.txt'] as any) + + await expect(makeBuilder().testOutputArtifacts('.charm')).rejects.toThrow( + 'No .charm files produced by build' + ) +}) + +test('CraftBuilder.outputArtifacts returns all matching files', async () => { + expect.assertions(1) + + jest + .spyOn(fs.promises, 'readdir') + .mockResolvedValue(['a.charm', 'b.charm', 'readme.txt'] as any) + + await expect( + makeBuilder({projectRoot: 'project-root'}).testOutputArtifacts('.charm') + ).resolves.toEqual(['project-root/a.charm', 'project-root/b.charm']) +}) + +test('CraftBuilder.outputArtifact returns the first artifact of the declared type', async () => { + expect.assertions(1) + + jest + .spyOn(fs.promises, 'readdir') + .mockResolvedValue(['a.charm', 'b.charm'] as any) + + await expect( + makeBuilder({projectRoot: 'project-root'}).outputArtifact() + ).resolves.toEqual('project-root/a.charm') +}) + +test('CraftBuilder.outputArtifact throws when no artifacts are found', async () => { + expect.assertions(1) + + jest + .spyOn(fs.promises, 'readdir') + .mockResolvedValue(['not-a-charm.txt'] as any) + + await expect(makeBuilder().outputArtifact()).rejects.toThrow( + 'No .charm files produced by build' + ) +}) diff --git a/tests/rockcraft-pack.test.ts b/tests/rockcraft-pack.test.ts index c8f2354..c316aec 100644 --- a/tests/rockcraft-pack.test.ts +++ b/tests/rockcraft-pack.test.ts @@ -1,7 +1,5 @@ // -*- mode: javascript; js-indent-level: 2 -*- -import * as os from 'os' -import * as path from 'path' import * as exec from '@actions/exec' import * as build from '../src/rockcraft-pack' import * as tools from '../src/tools' @@ -11,593 +9,104 @@ afterEach(() => { jest.restoreAllMocks() }) -test('RockcraftBuilder expands tilde in project root', () => { - let builder = new build.RockcraftBuilder({ - projectRoot: '~', - rockcraftChannel: 'edge', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: '', - ignore: '' - }) - expect(builder.projectRoot).toBe(os.homedir()) - - builder = new build.RockcraftBuilder({ - projectRoot: '~/foo/bar', - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: '', - ignore: '' - }) - expect(builder.projectRoot).toBe(path.join(os.homedir(), 'foo/bar')) -}) - -test('RockcraftBuilder.pack runs a rock build', async () => { - expect.assertions(5) - - const user = 'ubuntu' - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const shellUser = jest - .spyOn(tools, 'shellUser') - .mockImplementation((): string => user) - const execMock = jest - .spyOn(exec, 'exec') - .mockImplementation( - async (program: string, args?: string[]): Promise => { - return 0 - } - ) - - const projectDir = 'project-root' - const builder = new build.RockcraftBuilder({ - projectRoot: projectDir, - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'debug', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: '', - ignore: '' - }) - await builder.pack() - - expect(ensureSnapd).toHaveBeenCalled() - expect(ensureLXD).toHaveBeenCalled() - expect(ensureCraftTool).toHaveBeenCalled() - expect(shellUser).toHaveBeenCalled() - expect(execMock).toHaveBeenCalledWith( - 'sudo', - [ - '--preserve-env', - '--user', - user, - 'rockcraft', - 'pack', - '--verbosity', - 'debug' - ], - { - cwd: projectDir - } - ) -}) - -test('RockcraftBuilder.build can set the Rockcraft channel', async () => { - expect.assertions(1) - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const execMock = jest - .spyOn(exec, 'exec') - .mockImplementation( - async (program: string, args?: string[]): Promise => { - return 0 - } - ) - - const builder = new build.RockcraftBuilder({ - projectRoot: '.', - rockcraftChannel: 'test-channel', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '', - runRockcraftTest: false, - buildPro: '', - ignore: '' - }) - await builder.pack() - - expect(ensureCraftTool).toHaveBeenCalledWith('rockcraft', 'test-channel', '') -}) - -test('RockcraftBuilder.build can set the Rockcraft revision', async () => { - expect.assertions(1) - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const execMock = jest - .spyOn(exec, 'exec') - .mockImplementation( - async (program: string, args?: string[]): Promise => { - return 0 - } - ) - - const builder = new build.RockcraftBuilder({ - projectRoot: '.', - rockcraftChannel: 'channel', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '123', - runRockcraftTest: false, - buildPro: '', - ignore: '' - }) - await builder.pack() - - expect(ensureCraftTool).toHaveBeenCalledWith('rockcraft', 'channel', '123') -}) - -test('RockcraftBuilder.build can pass known verbosity', async () => { - expect.assertions(2) - - const user = 'ubuntu' - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const shellUser = jest - .spyOn(tools, 'shellUser') - .mockImplementation((): string => user) - const execMock = jest - .spyOn(exec, 'exec') - .mockImplementation( - async (program: string, args?: string[]): Promise => { - return 0 - } - ) +function mockSetup(user = 'ubuntu') { + return { + ensureSnapd: jest + .spyOn(tools, 'ensureSnapd') + .mockImplementation(async (): Promise => {}), + ensureLXD: jest + .spyOn(tools, 'ensureLXD') + .mockImplementation(async (): Promise => {}), + ensureCraftTool: jest + .spyOn(tools, 'ensureCraftTool') + .mockImplementation(async (): Promise => {}), + shellUser: jest + .spyOn(tools, 'shellUser') + .mockImplementation((): string => user), + execMock: jest + .spyOn(exec, 'exec') + .mockImplementation( + async (program: string, args?: string[]): Promise => 0 + ) + } +} - const builder = new build.RockcraftBuilder({ +function makeBuilder( + overrides: Partial = {} +): build.RockcraftBuilder { + return new build.RockcraftBuilder({ projectRoot: '.', - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: '', - ignore: '' + channel: 'stable', + verbosity: '', + revision: '', + runTests: false, + pro: '', + ignore: '', + ...overrides }) - await builder.pack() - - expect(execMock).toHaveBeenCalledWith( - 'sudo', - [ - '--preserve-env', - '--user', - user, - 'rockcraft', - 'pack', - '--verbosity', - 'trace' - ], - expect.anything() - ) - - const badBuilder = () => { - new build.RockcraftBuilder({ - projectRoot: '.', - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'fake-verbosity', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: '', - ignore: '' - }) - } - expect(badBuilder).toThrow() -}) +} test('RockcraftBuilder.build can ignore unmaintained', async () => { expect.assertions(1) - const user = 'ubuntu' - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const haveFlag = jest + const {execMock} = mockSetup() + jest .spyOn(tools, 'haveFlag') .mockImplementation(async (): Promise => true) - const shellUser = jest - .spyOn(tools, 'shellUser') - .mockImplementation((): string => user) - const execMock = jest - .spyOn(exec, 'exec') - .mockImplementation( - async (program: string, args?: string[]): Promise => { - return 0 - } - ) - const builder = new build.RockcraftBuilder({ - projectRoot: '.', - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: '', - ignore: 'unmaintained' - }) - await builder.pack() + await makeBuilder({ignore: 'unmaintained', verbosity: 'trace'}).pack() expect(execMock).toHaveBeenCalledWith( 'sudo', [ '--preserve-env', '--user', - user, + 'ubuntu', 'rockcraft', 'pack', '--verbosity', 'trace', '--ignore=unmaintained' ], - { - cwd: '.' - } + {cwd: '.'} ) }) -test('RockcraftBuilder.pack runs a rock build and test', async () => { - expect.assertions(7) - - const user = 'ubuntu' - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const shellUser = jest - .spyOn(tools, 'shellUser') - .mockImplementation((): string => user) - const haveSubcommand = jest - .spyOn(tools, 'haveSubcommand') - .mockImplementation(async (): Promise => true) - const fileExists = jest - .spyOn(tools, 'fileExists') - .mockImplementation((path: string) => true) - const execMock = jest - .spyOn(exec, 'exec') - .mockImplementation( - async (program: string, args?: string[]): Promise => { - return 0 - } - ) +test('RockcraftBuilder.build fails if ignore flag is not supported', async () => { + expect.assertions(1) - const projectDir = 'project-root' - const builder = new build.RockcraftBuilder({ - projectRoot: projectDir, - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'debug', - rockcraftRevision: '1', - runRockcraftTest: true, - buildPro: '', - ignore: '' - }) - await builder.pack() + mockSetup() + jest + .spyOn(tools, 'haveFlag') + .mockImplementation(async (): Promise => false) - expect(ensureSnapd).toHaveBeenCalled() - expect(ensureLXD).toHaveBeenCalled() - expect(ensureCraftTool).toHaveBeenCalled() - expect(shellUser).toHaveBeenCalled() - expect(haveSubcommand).toHaveBeenCalled() - expect(fileExists).toHaveBeenCalledWith('project-root/spread.yaml') - expect(execMock).toHaveBeenCalledWith( - 'sudo', - [ - '--preserve-env', - '--user', - user, - 'rockcraft', - 'test', - '--verbosity', - 'debug' - ], - { - cwd: projectDir - } + await expect(makeBuilder({ignore: 'unmaintained'}).pack()).rejects.toThrow( + 'This rockcraft version does not support --ignore.' ) }) test('RockcraftBuilder.pack fails if test is set to true and no spread.yaml is found', async () => { - expect.assertions(5) - - const user = 'ubuntu' - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const fileExists = jest - .spyOn(tools, 'fileExists') - .mockImplementation((path: string) => false) + expect.assertions(1) - const projectDir = 'project-root' - const builder = new build.RockcraftBuilder({ - projectRoot: projectDir, - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'debug', - rockcraftRevision: '1', - runRockcraftTest: true, - buildPro: '', - ignore: '' - }) + mockSetup() + jest.spyOn(tools, 'fileExists').mockImplementation((): boolean => false) - await expect(builder.pack()).rejects.toThrow( - 'Cannot run tests. Missing project-root/spread.yaml file.' - ) - expect(ensureSnapd).toHaveBeenCalled() - expect(ensureLXD).toHaveBeenCalled() - expect(ensureCraftTool).toHaveBeenCalled() - expect(fileExists).toHaveBeenCalledWith('project-root/spread.yaml') + await expect( + makeBuilder({projectRoot: 'project-root', runTests: true}).pack() + ).rejects.toThrow('Cannot run tests. Missing project-root/spread.yaml file.') }) test('RockcraftBuilder.pack fails if test is set to true and rockcraft test is invalid', async () => { - expect.assertions(6) - - const user = 'ubuntu' - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const haveSubcommand = jest - .spyOn(tools, 'haveSubcommand') - .mockImplementation(async (): Promise => false) - const fileExists = jest - .spyOn(tools, 'fileExists') - .mockImplementation((path: string) => true) - - const projectDir = 'project-root' - const builder = new build.RockcraftBuilder({ - projectRoot: projectDir, - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'debug', - rockcraftRevision: '1', - runRockcraftTest: true, - buildPro: '', - ignore: '' - }) - - await expect(builder.pack()).rejects.toThrow( - 'Cannot run tests. rockcraft test is not a valid command.' - ) - expect(ensureSnapd).toHaveBeenCalled() - expect(ensureLXD).toHaveBeenCalled() - expect(ensureCraftTool).toHaveBeenCalled() - expect(haveSubcommand).toHaveBeenCalled() - expect(fileExists).toHaveBeenCalledWith('project-root/spread.yaml') -}) - -test('RockcraftBuilder.outputRock fails if there are no rocks', async () => { - expect.assertions(2) - - const projectDir = 'project-root' - const builder = new build.RockcraftBuilder({ - projectRoot: projectDir, - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: '', - ignore: '' - }) - - const readdir = jest - .spyOn(fs.promises, 'readdir') - .mockResolvedValue(['not-a-rock'] as any) - - await expect(builder.outputRock()).rejects.toThrow( - 'No .rock files produced by build' - ) - expect(readdir).toHaveBeenCalled() -}) - -test('RockcraftBuilder.outputRock returns the first rock', async () => { - expect.assertions(2) - - const projectDir = 'project-root' - const builder = new build.RockcraftBuilder({ - projectRoot: projectDir, - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: '', - ignore: '' - }) - - const readdir = jest - .spyOn(fs.promises, 'readdir') - .mockResolvedValue(['one.rock', 'two.rock'] as any) - - await expect(builder.outputRock()).resolves.toEqual('project-root/one.rock') - expect(readdir).toHaveBeenCalled() -}) - -test('RockcraftBuilder.build can pass pro option', async () => { expect.assertions(1) - const user = 'ubuntu' - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const shellUser = jest - .spyOn(tools, 'shellUser') - .mockImplementation((): string => user) - const haveFlag = jest - .spyOn(tools, 'haveFlag') - .mockImplementation(async (): Promise => true) - const execMock = jest - .spyOn(exec, 'exec') - .mockImplementation( - async (program: string, args?: string[]): Promise => { - return 0 - } - ) - - const builder = new build.RockcraftBuilder({ - projectRoot: '.', - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: 'esm-apps,esm-infra', - ignore: '' - }) - await builder.pack() - - expect(execMock).toHaveBeenCalledWith( - 'sudo', - [ - '--preserve-env', - '--user', - 'ubuntu', - 'rockcraft', - 'pack', - '--pro=esm-apps,esm-infra', - '--verbosity', - 'trace' - ], - expect.anything() - ) -}) - -test('RockcraftBuilder.build fails if pro option is not available in rockcraft', async () => { - expect.assertions(2) - - const user = 'ubuntu' - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const shellUser = jest - .spyOn(tools, 'shellUser') - .mockImplementation((): string => user) - const haveFlag = jest - .spyOn(tools, 'haveFlag') + mockSetup() + jest + .spyOn(tools, 'haveSubcommand') .mockImplementation(async (): Promise => false) + jest.spyOn(tools, 'fileExists').mockImplementation((): boolean => true) - const builder = new build.RockcraftBuilder({ - projectRoot: '.', - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: 'fips-updates', - ignore: '' - }) - - await expect(builder.pack()).rejects.toThrow( - 'Cannot build pro rock. This rockcraft version does not support pro.' - ) - expect(haveFlag).toHaveBeenCalled() + await expect( + makeBuilder({projectRoot: 'project-root', runTests: true}).pack() + ).rejects.toThrow('Cannot run tests. rockcraft test is not a valid command.') }) -test('RockcraftBuilder.build fails if pro argument is invalid', async () => { - expect.assertions(1) - - const user = 'ubuntu' - - const ensureSnapd = jest - .spyOn(tools, 'ensureSnapd') - .mockImplementation(async (): Promise => {}) - const ensureLXD = jest - .spyOn(tools, 'ensureLXD') - .mockImplementation(async (): Promise => {}) - const ensureCraftTool = jest - .spyOn(tools, 'ensureCraftTool') - .mockImplementation(async (): Promise => {}) - const shellUser = jest - .spyOn(tools, 'shellUser') - .mockImplementation((): string => user) - const haveFlag = jest - .spyOn(tools, 'haveFlag') - .mockImplementation(async (): Promise => true) - - const builder = new build.RockcraftBuilder({ - projectRoot: '.', - rockcraftChannel: 'stable', - rockcraftPackVerbosity: 'trace', - rockcraftRevision: '1', - runRockcraftTest: false, - buildPro: 'fips-updates another-command', - ignore: '' - }) - - await expect(builder.pack()).rejects.toThrow( - "Invalid argument 'fips-updates another-command' in field 'pro'" - ) -}) From b510c47af19308806a487c7a7da1b215c921c9a4 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 16 Jun 2026 08:57:38 -0400 Subject: [PATCH 03/11] refactor: abstracted '-pack-action.ts' files --- src/pack-action.ts | 32 +++++++++ src/rockcraft-pack-action.ts | 39 ++-------- tests/pack-action.test.ts | 133 +++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 33 deletions(-) create mode 100644 src/pack-action.ts create mode 100644 tests/pack-action.test.ts diff --git a/src/pack-action.ts b/src/pack-action.ts new file mode 100644 index 0000000..a8851fa --- /dev/null +++ b/src/pack-action.ts @@ -0,0 +1,32 @@ +// -*- mode: javascript; js-indent-level: 2 -*- + +import * as core from '@actions/core' +import {CraftBuilder, CraftBuilderOptions} from './craft-builder' + +export function readBaseInputs(channelInput = 'channel'): CraftBuilderOptions { + return { + projectRoot: core.getInput('path'), + channel: core.getInput(channelInput) || 'stable', + revision: core.getInput('revision') || '', + verbosity: core.getInput('verbosity'), + pro: core.getInput('pro') || '', + runTests: core.getInput('test').toLowerCase() === 'true' + } +} + +export async function runPackAction( + builder: CraftBuilder, + outputName: string +): Promise { + try { + if (!builder.revision) { + core.info( + `${builder.toolName} revision not provided. Installing from ${builder.channel}` + ) + } + await builder.pack() + core.setOutput(outputName, await builder.outputArtifact()) + } catch (error) { + core.setFailed((error as Error)?.message) + } +} diff --git a/src/rockcraft-pack-action.ts b/src/rockcraft-pack-action.ts index 5ba3c51..e972106 100644 --- a/src/rockcraft-pack-action.ts +++ b/src/rockcraft-pack-action.ts @@ -2,38 +2,11 @@ import * as core from '@actions/core' import {RockcraftBuilder} from './rockcraft-pack' +import {readBaseInputs, runPackAction} from './pack-action' -async function run(): Promise { - try { - const projectRoot = core.getInput('path') - core.info(`Building rock in "${projectRoot}"...`) - const pro = core.getInput('pro') || '' - const rockcraftRevision = core.getInput('revision') || '' - const rockcraftChannel = core.getInput('rockcraft-channel') || 'stable' - const runTests = core.getInput('test').toLowerCase() === 'true' - const ignore = core.getInput('ignore') - if (rockcraftRevision.length < 1) { - core.warning( - `Rockcraft revision not provided. Installing from ${rockcraftChannel}` - ) - } - const rockcraftPackVerbosity = core.getInput('verbosity') +const builder = new RockcraftBuilder({ + ...readBaseInputs('rockcraft-channel'), + ignore: core.getInput('ignore') +}) - const builder = new RockcraftBuilder({ - projectRoot, - channel: rockcraftChannel, - verbosity: rockcraftPackVerbosity, - revision: rockcraftRevision, - runTests, - pro, - ignore - }) - await builder.pack() - const rock = await builder.outputArtifact() - core.setOutput('rock', rock) - } catch (error) { - core.setFailed((error as Error)?.message) - } -} - -void run() +void runPackAction(builder, 'rock') diff --git a/tests/pack-action.test.ts b/tests/pack-action.test.ts new file mode 100644 index 0000000..93510e6 --- /dev/null +++ b/tests/pack-action.test.ts @@ -0,0 +1,133 @@ +// -*- mode: javascript; js-indent-level: 2 -*- + +import * as core from '@actions/core' +import {readBaseInputs, runPackAction} from '../src/pack-action' +import {CraftBuilder} from '../src/craft-builder' + +afterEach(() => { + jest.restoreAllMocks() +}) + +function mockInputs(inputs: Record) { + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + return inputs[name] ?? '' + }) +} + +// Minimal stub matching the CraftBuilder interface needed by runPackAction. +function makeStubBuilder( + overrides: Partial<{ + toolName: string + channel: string + revision: string + pack: () => Promise + outputArtifact: () => Promise + }> = {} +) { + return { + toolName: 'test-tool', + channel: 'stable', + revision: '', + pack: jest.fn(async () => {}), + outputArtifact: jest.fn(async () => 'project-root/output.charm'), + ...overrides + } as unknown as CraftBuilder +} + +// readBaseInputs + +test('readBaseInputs reads standard inputs', () => { + mockInputs({ + path: 'my-project', + channel: 'edge', + revision: '42', + verbosity: 'debug', + pro: 'esm-apps', + test: 'true' + }) + + expect(readBaseInputs()).toEqual({ + projectRoot: 'my-project', + channel: 'edge', + revision: '42', + verbosity: 'debug', + pro: 'esm-apps', + runTests: true + }) +}) + +test('readBaseInputs defaults channel to stable when empty', () => { + mockInputs({path: '.'}) + + expect(readBaseInputs()).toMatchObject({channel: 'stable'}) +}) + +test('readBaseInputs uses a custom channel input name', () => { + mockInputs({'rockcraft-channel': 'candidate'}) + + expect(readBaseInputs('rockcraft-channel')).toMatchObject({ + channel: 'candidate' + }) +}) + +test('readBaseInputs parses runTests as false when input is not "true"', () => { + mockInputs({test: 'false'}) + + expect(readBaseInputs()).toMatchObject({runTests: false}) +}) + +// runPackAction + +test('runPackAction calls pack and sets output', async () => { + expect.assertions(2) + + const setOutput = jest.spyOn(core, 'setOutput').mockImplementation(() => {}) + jest.spyOn(core, 'info').mockImplementation(() => {}) + const builder = makeStubBuilder({revision: '1'}) + + await runPackAction(builder, 'charm') + + expect(builder.pack).toHaveBeenCalled() + expect(setOutput).toHaveBeenCalledWith('charm', 'project-root/output.charm') +}) + +test('runPackAction logs info when revision is not set', async () => { + expect.assertions(1) + + const info = jest.spyOn(core, 'info').mockImplementation(() => {}) + jest.spyOn(core, 'setOutput').mockImplementation(() => {}) + const builder = makeStubBuilder({revision: ''}) + + await runPackAction(builder, 'charm') + + expect(info).toHaveBeenCalled() +}) + +test('runPackAction does not log info when revision is set', async () => { + expect.assertions(1) + + const info = jest.spyOn(core, 'info').mockImplementation(() => {}) + jest.spyOn(core, 'setOutput').mockImplementation(() => {}) + const builder = makeStubBuilder({revision: '42'}) + + await runPackAction(builder, 'charm') + + expect(info).not.toHaveBeenCalled() +}) + +test('runPackAction calls setFailed on error', async () => { + expect.assertions(1) + + const setFailed = jest.spyOn(core, 'setFailed').mockImplementation(() => {}) + jest.spyOn(core, 'info').mockImplementation(() => {}) + const builder = makeStubBuilder({ + revision: '1', + pack: jest.fn(async () => { + throw new Error('pack failed') + }) + }) + + await runPackAction(builder, 'charm') + + expect(setFailed).toHaveBeenCalledWith('pack failed') +}) From c86b9cb54e135ea464a054829c40459e8207fc34 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 16 Jun 2026 11:11:34 -0400 Subject: [PATCH 04/11] refactor: simplify output artifact collection --- src/craft-builder.ts | 12 +---------- src/pack-action.ts | 8 +++++++- tests/craft-builder.test.ts | 37 ++++------------------------------ tests/pack-action.test.ts | 40 +++++++++++++++++++++++++++++++++++-- 4 files changed, 50 insertions(+), 47 deletions(-) diff --git a/src/craft-builder.ts b/src/craft-builder.ts index cbbee53..6e04bb4 100644 --- a/src/craft-builder.ts +++ b/src/craft-builder.ts @@ -104,21 +104,11 @@ export abstract class CraftBuilder { await this.doPack(await this.resolvePackSubcommand()) } - async outputArtifact(): Promise { - const artifacts = await this.outputArtifacts(this.artifactType) - if (artifacts.length > 1) { - core.warning( - `Multiple ${this.artifactType} files found in ${this.projectRoot}` - ) - } - return artifacts[0] - } - async #readdir(dir: string): Promise { return await fs.promises.readdir(dir) } - protected async outputArtifacts(extension: string): Promise { + async findArtifacts(extension: string): Promise { const files = await this.#readdir(this.projectRoot) const artifacts = files .filter(name => name.endsWith(extension)) diff --git a/src/pack-action.ts b/src/pack-action.ts index a8851fa..627dbed 100644 --- a/src/pack-action.ts +++ b/src/pack-action.ts @@ -25,7 +25,13 @@ export async function runPackAction( ) } await builder.pack() - core.setOutput(outputName, await builder.outputArtifact()) + const artifacts = await builder.findArtifacts(builder.artifactType) + if (artifacts.length > 1) { + core.warning( + `Multiple ${builder.artifactType} files found in ${builder.projectRoot}` + ) + } + core.setOutput(outputName, artifacts[0]) } catch (error) { core.setFailed((error as Error)?.message) } diff --git a/tests/craft-builder.test.ts b/tests/craft-builder.test.ts index a00e9d1..c7ef4ca 100644 --- a/tests/craft-builder.test.ts +++ b/tests/craft-builder.test.ts @@ -10,11 +10,6 @@ import * as tools from '../src/tools' class TestBuilder extends CraftBuilder { toolName = 'test-tool' artifactType = '.charm' - - // Expose the protected method so tests can call it directly. - async testOutputArtifacts(ext: string): Promise { - return this.outputArtifacts(ext) - } } function makeBuilder(overrides: Partial = {}): TestBuilder { @@ -251,19 +246,19 @@ test('CraftBuilder.pack fails when pro argument is invalid', async () => { ).rejects.toThrow("Invalid argument 'fips-updates another-command' in field 'pro'") }) -test('CraftBuilder.outputArtifacts throws when no matching files are found', async () => { +test('CraftBuilder.findArtifacts throws when no matching files are found', async () => { expect.assertions(1) jest .spyOn(fs.promises, 'readdir') .mockResolvedValue(['other-file.txt'] as any) - await expect(makeBuilder().testOutputArtifacts('.charm')).rejects.toThrow( + await expect(makeBuilder().findArtifacts('.charm')).rejects.toThrow( 'No .charm files produced by build' ) }) -test('CraftBuilder.outputArtifacts returns all matching files', async () => { +test('CraftBuilder.findArtifacts returns all matching files', async () => { expect.assertions(1) jest @@ -271,30 +266,6 @@ test('CraftBuilder.outputArtifacts returns all matching files', async () => { .mockResolvedValue(['a.charm', 'b.charm', 'readme.txt'] as any) await expect( - makeBuilder({projectRoot: 'project-root'}).testOutputArtifacts('.charm') + makeBuilder({projectRoot: 'project-root'}).findArtifacts('.charm') ).resolves.toEqual(['project-root/a.charm', 'project-root/b.charm']) }) - -test('CraftBuilder.outputArtifact returns the first artifact of the declared type', async () => { - expect.assertions(1) - - jest - .spyOn(fs.promises, 'readdir') - .mockResolvedValue(['a.charm', 'b.charm'] as any) - - await expect( - makeBuilder({projectRoot: 'project-root'}).outputArtifact() - ).resolves.toEqual('project-root/a.charm') -}) - -test('CraftBuilder.outputArtifact throws when no artifacts are found', async () => { - expect.assertions(1) - - jest - .spyOn(fs.promises, 'readdir') - .mockResolvedValue(['not-a-charm.txt'] as any) - - await expect(makeBuilder().outputArtifact()).rejects.toThrow( - 'No .charm files produced by build' - ) -}) diff --git a/tests/pack-action.test.ts b/tests/pack-action.test.ts index 93510e6..48ca83c 100644 --- a/tests/pack-action.test.ts +++ b/tests/pack-action.test.ts @@ -20,16 +20,20 @@ function makeStubBuilder( toolName: string channel: string revision: string + artifactType: string + projectRoot: string pack: () => Promise - outputArtifact: () => Promise + findArtifacts: (ext: string) => Promise }> = {} ) { return { toolName: 'test-tool', channel: 'stable', revision: '', + artifactType: '.charm', + projectRoot: 'project-root', pack: jest.fn(async () => {}), - outputArtifact: jest.fn(async () => 'project-root/output.charm'), + findArtifacts: jest.fn(async () => ['project-root/output.charm']), ...overrides } as unknown as CraftBuilder } @@ -131,3 +135,35 @@ test('runPackAction calls setFailed on error', async () => { expect(setFailed).toHaveBeenCalledWith('pack failed') }) + +test('runPackAction warns when multiple artifacts are found', async () => { + expect.assertions(1) + + jest.spyOn(core, 'setOutput').mockImplementation(() => {}) + jest.spyOn(core, 'info').mockImplementation(() => {}) + const warning = jest.spyOn(core, 'warning').mockImplementation(() => {}) + const builder = makeStubBuilder({ + revision: '1', + findArtifacts: jest.fn(async () => [ + 'project-root/a.charm', + 'project-root/b.charm' + ]) + }) + + await runPackAction(builder, 'charm') + + expect(warning).toHaveBeenCalled() +}) + +test('runPackAction does not warn when only one artifact is found', async () => { + expect.assertions(1) + + jest.spyOn(core, 'setOutput').mockImplementation(() => {}) + jest.spyOn(core, 'info').mockImplementation(() => {}) + const warning = jest.spyOn(core, 'warning').mockImplementation(() => {}) + const builder = makeStubBuilder({revision: '1'}) + + await runPackAction(builder, 'charm') + + expect(warning).not.toHaveBeenCalled() +}) From 9af81c97563c5125911a25d5622715637651693c Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 16 Jun 2026 11:24:35 -0400 Subject: [PATCH 05/11] style: autoformat tests --- tests/craft-builder.test.ts | 14 +++++++++----- tests/rockcraft-pack.test.ts | 1 - tests/tools.test.ts | 12 ++++++++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/craft-builder.test.ts b/tests/craft-builder.test.ts index c7ef4ca..5bdbd2e 100644 --- a/tests/craft-builder.test.ts +++ b/tests/craft-builder.test.ts @@ -12,7 +12,9 @@ class TestBuilder extends CraftBuilder { artifactType = '.charm' } -function makeBuilder(overrides: Partial = {}): TestBuilder { +function makeBuilder( + overrides: Partial = {} +): TestBuilder { return new TestBuilder({ projectRoot: '.', channel: 'stable', @@ -170,9 +172,9 @@ test('CraftBuilder.pack fails when runTests is true and tool has no test subcomm .spyOn(tools, 'haveSubcommand') .mockImplementation(async (): Promise => false) - await expect( - makeBuilder({runTests: true}).pack() - ).rejects.toThrow('Cannot run tests. test-tool test is not a valid command.') + await expect(makeBuilder({runTests: true}).pack()).rejects.toThrow( + 'Cannot run tests. test-tool test is not a valid command.' + ) }) test('CraftBuilder.pack includes --verbosity flag when verbosity is set', async () => { @@ -243,7 +245,9 @@ test('CraftBuilder.pack fails when pro argument is invalid', async () => { await expect( makeBuilder({pro: 'fips-updates another-command'}).pack() - ).rejects.toThrow("Invalid argument 'fips-updates another-command' in field 'pro'") + ).rejects.toThrow( + "Invalid argument 'fips-updates another-command' in field 'pro'" + ) }) test('CraftBuilder.findArtifacts throws when no matching files are found', async () => { diff --git a/tests/rockcraft-pack.test.ts b/tests/rockcraft-pack.test.ts index c316aec..c44355e 100644 --- a/tests/rockcraft-pack.test.ts +++ b/tests/rockcraft-pack.test.ts @@ -109,4 +109,3 @@ test('RockcraftBuilder.pack fails if test is set to true and rockcraft test is i makeBuilder({projectRoot: 'project-root', runTests: true}).pack() ).rejects.toThrow('Cannot run tests. rockcraft test is not a valid command.') }) - diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 8387a28..2999388 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -537,7 +537,11 @@ test('haveFlag returns true if the flag is present in the help output', async () const execMock = jest .spyOn(exec, 'exec') .mockImplementation( - async (program: string, args?: string[], options?: any): Promise => { + async ( + program: string, + args?: string[], + options?: any + ): Promise => { options?.listeners?.stdout?.(Buffer.from('--pro\n--other-flag')) return 0 } @@ -557,7 +561,11 @@ test('haveFlag returns false if the flag is absent from the help output', async jest .spyOn(exec, 'exec') .mockImplementation( - async (program: string, args?: string[], options?: any): Promise => { + async ( + program: string, + args?: string[], + options?: any + ): Promise => { options?.listeners?.stdout?.(Buffer.from('--other-flag')) return 0 } From fad8fc3af2971046867ffe7b48a28daae042d047 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 16 Jun 2026 11:36:31 -0400 Subject: [PATCH 06/11] feat: make package step more modular --- dist/rockcraft-pack-action/index.js | 239 +++++++++++++++------------- package.json | 2 +- scripts/pack.mjs | 14 ++ 3 files changed, 144 insertions(+), 111 deletions(-) create mode 100644 scripts/pack.mjs diff --git a/dist/rockcraft-pack-action/index.js b/dist/rockcraft-pack-action/index.js index 128a538..a1b19ba 100644 --- a/dist/rockcraft-pack-action/index.js +++ b/dist/rockcraft-pack-action/index.js @@ -19674,7 +19674,7 @@ var require_core = __commonJS({ process.env["PATH"] = `${inputPath}${path2.delimiter}${process.env["PATH"]}`; } exports2.addPath = addPath; - function getInput2(name, options) { + function getInput3(name, options) { const val = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] || ""; if (options && options.required && !val) { throw new Error(`Input required and not supplied: ${name}`); @@ -19684,9 +19684,9 @@ var require_core = __commonJS({ } return val.trim(); } - exports2.getInput = getInput2; + exports2.getInput = getInput3; function getMultilineInput(name, options) { - const inputs = getInput2(name, options).split("\n").filter((x) => x !== ""); + const inputs = getInput3(name, options).split("\n").filter((x) => x !== ""); if (options && options.trimWhitespace === false) { return inputs; } @@ -19696,7 +19696,7 @@ var require_core = __commonJS({ function getBooleanInput(name, options) { const trueValue = ["true", "True", "TRUE"]; const falseValue = ["false", "False", "FALSE"]; - const val = getInput2(name, options); + const val = getInput3(name, options); if (trueValue.includes(val)) return true; if (falseValue.includes(val)) @@ -19735,10 +19735,10 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``); (0, command_1.issueCommand)("error", (0, utils_1.toCommandProperties)(properties), message instanceof Error ? message.toString() : message); } exports2.error = error; - function warning3(message, properties = {}) { + function warning2(message, properties = {}) { (0, command_1.issueCommand)("warning", (0, utils_1.toCommandProperties)(properties), message instanceof Error ? message.toString() : message); } - exports2.warning = warning3; + exports2.warning = warning2; function notice(message, properties = {}) { (0, command_1.issueCommand)("notice", (0, utils_1.toCommandProperties)(properties), message instanceof Error ? message.toString() : message); } @@ -19809,15 +19809,9 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``); }); // src/rockcraft-pack-action.ts -var core3 = __toESM(require_core()); - -// src/rockcraft-pack.ts -var core2 = __toESM(require_core()); -var exec3 = __toESM(require_exec()); -var fs2 = __toESM(require("fs")); -var path = __toESM(require("path")); +var core4 = __toESM(require_core()); -// src/common/tools.ts +// src/tools.ts var core = __toESM(require_core()); var exec = __toESM(require_exec()); var fs = __toESM(require("fs")); @@ -19856,9 +19850,7 @@ async function haveFlag(tool, flag) { return output.includes(flag); } async function haveSubcommand(tool, subcommand) { - return await exec.exec("sudo", [tool, subcommand, "-h"], { - ignoreReturnCode: true - }) === 0; + return await exec.exec(tool, [subcommand, "-h"], { ignoreReturnCode: true }) === 0; } async function ensureSnapd() { const haveSnapd = await haveExecutable("/usr/bin/snap"); @@ -19948,132 +19940,159 @@ async function ensureCraftTool(name, channel, revision) { ]); } -// src/rockcraft-pack.ts +// src/craft-builder.ts +var core2 = __toESM(require_core()); +var exec3 = __toESM(require_exec()); +var fs2 = __toESM(require("fs")); +var path = __toESM(require("path")); var allowedVerbosity = ["quiet", "brief", "verbose", "debug", "trace"]; -var RockcraftBuilder = class { +var CraftBuilder = class { projectRoot; - rockcraftChannel; - rockcraftPackVerbosity; - rockcraftRevision; - runRockcraftTest; - buildPro; - ignore; + channel; + revision; + verbosity; + pro; + runTests; constructor(options) { this.projectRoot = expandHome(options.projectRoot); - this.rockcraftChannel = options.rockcraftChannel; - this.rockcraftRevision = options.rockcraftRevision; - this.runRockcraftTest = options.runRockcraftTest; - this.buildPro = options.buildPro; - this.ignore = options.ignore; - if (allowedVerbosity.includes(options.rockcraftPackVerbosity)) { - this.rockcraftPackVerbosity = options.rockcraftPackVerbosity; + this.channel = options.channel; + this.revision = options.revision; + this.pro = options.pro ?? ""; + this.runTests = options.runTests ?? false; + if (!options.verbosity || allowedVerbosity.includes(options.verbosity)) { + this.verbosity = options.verbosity ?? ""; } else { throw new Error( - 'Invalid verbosity "${options.rockcraftPackVerbosity}".Allowed values are ${allowedVerbosity.join(", ")}.' + `Invalid verbosity "${options.verbosity}". Allowed values are ${allowedVerbosity.join(", ")}.` ); } } - async pack() { - core2.startGroup("Installing Rockcraft plus dependencies"); - await ensureSnapd(); - await ensureLXD(!!this.buildPro); - await ensureCraftTool("rockcraft", this.rockcraftChannel, this.rockcraftRevision); - core2.endGroup(); - const sudoArgs = ["--user", shellUser()]; - let rockcraft = "rockcraft pack"; - let rockcraftPackArgs = ""; - if (this.runRockcraftTest) { - const testFile = `${this.projectRoot}/spread.yaml`; - if (!fileExists(testFile)) { - throw new Error(`Cannot run tests. Missing ${testFile} file.`); - } else if (!await haveSubcommand("rockcraft", "test")) { - throw new Error( - "Cannot run tests. rockcraft test is not a valid command." - ); - } else { - rockcraft = "rockcraft test"; - } - } - if (this.buildPro) { - validateArgument(this.buildPro, "pro"); - if (!await haveFlag("rockcraft", "--pro")) { - throw new Error( - "Cannot build pro rock. This rockcraft version does not support pro." - ); + async buildPackArgs() { + const args = []; + if (this.pro) { + validateArgument(this.pro, "pro"); + if (!await haveFlag(this.toolName, "--pro")) { + throw new Error(`This ${this.toolName} version does not support --pro.`); } - rockcraftPackArgs = `${rockcraftPackArgs} --pro=${this.buildPro}`; + args.push(`--pro=${this.pro}`); } - if (this.rockcraftPackVerbosity) { - validateArgument(this.rockcraftPackVerbosity, "verbosity"); - rockcraftPackArgs = `${rockcraftPackArgs} --verbosity ${this.rockcraftPackVerbosity}`; + if (this.verbosity) { + validateArgument(this.verbosity, "verbosity"); + args.push("--verbosity", this.verbosity); } - if (this.ignore) { - validateArgument(this.ignore, "ignore"); - if (!await haveFlag("rockcraft", "--ignore")) { - throw new Error("This rockcraft version does not support ignore."); - } - rockcraftPackArgs = `${rockcraftPackArgs} --ignore=${this.ignore}`; - } - rockcraft = `${rockcraft} ${rockcraftPackArgs.trim()}`; + return args; + } + async doPack(subcommand) { + const packArgs = await this.buildPackArgs(); await exec3.exec( "sudo", - ["--preserve-env", ...sudoArgs, ...rockcraft.split(" ")], - { - cwd: this.projectRoot - } + [ + "--preserve-env", + "--user", + shellUser(), + this.toolName, + subcommand, + ...packArgs + ], + { cwd: this.projectRoot } ); } - // This wrapper is for the benefit of the tests, due to the crazy - // typing of fs.promises.readdir() + async resolvePackSubcommand() { + if (!this.runTests) return "pack"; + const testFile = `${this.projectRoot}/spread.yaml`; + if (!fileExists(testFile)) { + throw new Error(`Cannot run tests. Missing ${testFile} file.`); + } + if (!await haveSubcommand(this.toolName, "test")) { + throw new Error( + `Cannot run tests. ${this.toolName} test is not a valid command.` + ); + } + return "test"; + } + async pack() { + core2.startGroup(`Installing ${this.toolName} plus dependencies`); + await ensureSnapd(); + await ensureLXD(!!this.pro); + await ensureCraftTool(this.toolName, this.channel, this.revision); + core2.endGroup(); + await this.doPack(await this.resolvePackSubcommand()); + } async #readdir(dir) { return await fs2.promises.readdir(dir); } - async outputRock() { + async findArtifacts(extension) { const files = await this.#readdir(this.projectRoot); - const rocks = files.filter((name) => name.endsWith(".rock")); - if (rocks.length === 0) { - throw new Error("No .rock files produced by build"); + const artifacts = files.filter((name) => name.endsWith(extension)).map((name) => path.join(this.projectRoot, name)); + if (artifacts.length === 0) { + throw new Error(`No ${extension} files produced by build`); } - if (rocks.length > 1) { - core2.warning(`Multiple rocks found in ${this.projectRoot}`); + return artifacts; + } +}; + +// src/rockcraft-pack.ts +var RockcraftBuilder = class extends CraftBuilder { + toolName = "rockcraft"; + artifactType = ".rock"; + ignore; + constructor(options) { + super(options); + this.ignore = options.ignore; + } + async buildPackArgs() { + const args = await super.buildPackArgs(); + if (this.ignore) { + validateArgument(this.ignore, "ignore"); + if (!await haveFlag(this.toolName, "--ignore")) { + throw new Error( + `This ${this.toolName} version does not support --ignore.` + ); + } + args.push(`--ignore=${this.ignore}`); } - return path.join(this.projectRoot, rocks[0]); + return args; } }; -// src/rockcraft-pack-action.ts -async function run() { +// src/pack-action.ts +var core3 = __toESM(require_core()); +function readBaseInputs(channelInput = "channel") { + return { + projectRoot: core3.getInput("path"), + channel: core3.getInput(channelInput) || "stable", + revision: core3.getInput("revision") || "", + verbosity: core3.getInput("verbosity"), + pro: core3.getInput("pro") || "", + runTests: core3.getInput("test").toLowerCase() === "true" + }; +} +async function runPackAction(builder2, outputName) { try { - const projectRoot = core3.getInput("path"); - core3.info(`Building rock in "${projectRoot}"...`); - const buildPro = core3.getInput("pro") || ""; - const rockcraftRevision = core3.getInput("revision") || ""; - const rockcraftChannel = core3.getInput("rockcraft-channel") || "stable"; - const runRockcraftTest = core3.getInput("test").toLowerCase() === "true"; - const ignore = core3.getInput("ignore"); - if (rockcraftRevision.length < 1) { + if (!builder2.revision) { + core3.info( + `${builder2.toolName} revision not provided. Installing from ${builder2.channel}` + ); + } + await builder2.pack(); + const artifacts = await builder2.findArtifacts(builder2.artifactType); + if (artifacts.length > 1) { core3.warning( - `Rockcraft revision not provided. Installing from ${rockcraftChannel}` + `Multiple ${builder2.artifactType} files found in ${builder2.projectRoot}` ); } - const rockcraftPackVerbosity = core3.getInput("verbosity"); - const builder = new RockcraftBuilder({ - projectRoot, - rockcraftChannel, - rockcraftPackVerbosity, - rockcraftRevision, - runRockcraftTest, - buildPro, - ignore - }); - await builder.pack(); - const rock = await builder.outputRock(); - core3.setOutput("rock", rock); + core3.setOutput(outputName, artifacts[0]); } catch (error) { core3.setFailed(error?.message); } } -void run(); + +// src/rockcraft-pack-action.ts +var builder = new RockcraftBuilder({ + ...readBaseInputs("rockcraft-channel"), + ignore: core4.getInput("ignore") +}); +void runPackAction(builder, "rock"); /*! Bundled license information: undici/lib/fetch/body.js: diff --git a/package.json b/package.json index d603ec8..dab68fd 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "format-check": "prettier --check **/*.ts", "lint": "eslint src/**/*.ts", "lint-fix": "eslint src/**/*.ts --fix", - "pack": "for action in src/*action.ts; do esbuild --platform=node --target=node24 --bundle \"$action\" --outfile=dist/\"$(basename -s '.ts' $action)\"/index.js; done", + "pack": "node scripts/pack.mjs", "all": "npm install && npm run build && npm run format-check && npm run lint && npm test && npm run pack" }, "repository": { diff --git a/scripts/pack.mjs b/scripts/pack.mjs new file mode 100644 index 0000000..8475e17 --- /dev/null +++ b/scripts/pack.mjs @@ -0,0 +1,14 @@ +// -*- mode: javascript; js-indent-level: 2 -*- + +import {build} from 'esbuild' + +const entryPoints = ['src/rockcraft-pack-action.ts'] + +await build({ + entryPoints, + platform: 'node', + target: 'node24', + bundle: true, + entryNames: '[name]/index', + outdir: 'dist' +}) From 71742df6c56fc319f023d7d982200d95382191cf Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 16 Jun 2026 16:57:29 -0400 Subject: [PATCH 07/11] fix: avoid uncaught error with verbosity validation --- dist/rockcraft-pack-action/index.js | 14 ++++++-------- src/craft-builder.ts | 17 +++++++---------- tests/craft-builder.test.ts | 8 ++++++-- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/dist/rockcraft-pack-action/index.js b/dist/rockcraft-pack-action/index.js index a1b19ba..aa5e6b7 100644 --- a/dist/rockcraft-pack-action/index.js +++ b/dist/rockcraft-pack-action/index.js @@ -19957,15 +19957,9 @@ var CraftBuilder = class { this.projectRoot = expandHome(options.projectRoot); this.channel = options.channel; this.revision = options.revision; + this.verbosity = options.verbosity; this.pro = options.pro ?? ""; this.runTests = options.runTests ?? false; - if (!options.verbosity || allowedVerbosity.includes(options.verbosity)) { - this.verbosity = options.verbosity ?? ""; - } else { - throw new Error( - `Invalid verbosity "${options.verbosity}". Allowed values are ${allowedVerbosity.join(", ")}.` - ); - } } async buildPackArgs() { const args = []; @@ -19977,7 +19971,11 @@ var CraftBuilder = class { args.push(`--pro=${this.pro}`); } if (this.verbosity) { - validateArgument(this.verbosity, "verbosity"); + if (!allowedVerbosity.includes(this.verbosity)) { + throw new Error( + `Invalid verbosity "${this.verbosity}". Allowed values are ${allowedVerbosity.join(", ")}.` + ); + } args.push("--verbosity", this.verbosity); } return args; diff --git a/src/craft-builder.ts b/src/craft-builder.ts index 6e04bb4..c36d20e 100644 --- a/src/craft-builder.ts +++ b/src/craft-builder.ts @@ -32,17 +32,9 @@ export abstract class CraftBuilder { this.projectRoot = tools.expandHome(options.projectRoot) this.channel = options.channel this.revision = options.revision + this.verbosity = options.verbosity this.pro = options.pro ?? '' this.runTests = options.runTests ?? false - - if (!options.verbosity || allowedVerbosity.includes(options.verbosity)) { - this.verbosity = options.verbosity ?? '' - } else { - throw new Error( - `Invalid verbosity "${options.verbosity}". ` + - `Allowed values are ${allowedVerbosity.join(', ')}.` - ) - } } protected async buildPackArgs(): Promise { @@ -57,7 +49,12 @@ export abstract class CraftBuilder { } if (this.verbosity) { - tools.validateArgument(this.verbosity, 'verbosity') + if (!allowedVerbosity.includes(this.verbosity)) { + throw new Error( + `Invalid verbosity "${this.verbosity}". ` + + `Allowed values are ${allowedVerbosity.join(', ')}.` + ) + } args.push('--verbosity', this.verbosity) } diff --git a/tests/craft-builder.test.ts b/tests/craft-builder.test.ts index 5bdbd2e..2af1d12 100644 --- a/tests/craft-builder.test.ts +++ b/tests/craft-builder.test.ts @@ -57,8 +57,12 @@ test('CraftBuilder expands tilde in project root', () => { ) }) -test('CraftBuilder throws on invalid verbosity', () => { - expect(() => makeBuilder({verbosity: 'not-valid'})).toThrow() +test('CraftBuilder throws on invalid verbosity', async () => { + expect.assertions(1) + + mockSetup() + + await expect(makeBuilder({verbosity: 'not-valid'}).pack()).rejects.toThrow() }) test('CraftBuilder allows empty verbosity', () => { From 9a5a54ba8438ae16fef9fd74a7e2266e05ff3514 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Mon, 22 Jun 2026 16:04:17 -0400 Subject: [PATCH 08/11] chore: do not validate verbosity --- dist/rockcraft-pack-action/index.js | 6 ------ src/craft-builder.ts | 8 -------- tests/craft-builder.test.ts | 8 -------- 3 files changed, 22 deletions(-) diff --git a/dist/rockcraft-pack-action/index.js b/dist/rockcraft-pack-action/index.js index aa5e6b7..efc8457 100644 --- a/dist/rockcraft-pack-action/index.js +++ b/dist/rockcraft-pack-action/index.js @@ -19945,7 +19945,6 @@ var core2 = __toESM(require_core()); var exec3 = __toESM(require_exec()); var fs2 = __toESM(require("fs")); var path = __toESM(require("path")); -var allowedVerbosity = ["quiet", "brief", "verbose", "debug", "trace"]; var CraftBuilder = class { projectRoot; channel; @@ -19971,11 +19970,6 @@ var CraftBuilder = class { args.push(`--pro=${this.pro}`); } if (this.verbosity) { - if (!allowedVerbosity.includes(this.verbosity)) { - throw new Error( - `Invalid verbosity "${this.verbosity}". Allowed values are ${allowedVerbosity.join(", ")}.` - ); - } args.push("--verbosity", this.verbosity); } return args; diff --git a/src/craft-builder.ts b/src/craft-builder.ts index c36d20e..f4f40ee 100644 --- a/src/craft-builder.ts +++ b/src/craft-builder.ts @@ -6,8 +6,6 @@ import * as fs from 'fs' import * as path from 'path' import * as tools from './tools' -export const allowedVerbosity = ['quiet', 'brief', 'verbose', 'debug', 'trace'] - export interface CraftBuilderOptions { projectRoot: string channel: string @@ -49,12 +47,6 @@ export abstract class CraftBuilder { } if (this.verbosity) { - if (!allowedVerbosity.includes(this.verbosity)) { - throw new Error( - `Invalid verbosity "${this.verbosity}". ` + - `Allowed values are ${allowedVerbosity.join(', ')}.` - ) - } args.push('--verbosity', this.verbosity) } diff --git a/tests/craft-builder.test.ts b/tests/craft-builder.test.ts index 2af1d12..700ff97 100644 --- a/tests/craft-builder.test.ts +++ b/tests/craft-builder.test.ts @@ -57,14 +57,6 @@ test('CraftBuilder expands tilde in project root', () => { ) }) -test('CraftBuilder throws on invalid verbosity', async () => { - expect.assertions(1) - - mockSetup() - - await expect(makeBuilder({verbosity: 'not-valid'}).pack()).rejects.toThrow() -}) - test('CraftBuilder allows empty verbosity', () => { expect(() => makeBuilder({verbosity: ''})).not.toThrow() }) From b05ed81ffc20f2385fc6a41e9a9346141a5972e5 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Mon, 22 Jun 2026 16:13:48 -0400 Subject: [PATCH 09/11] chore: remove flag and subcommand validations --- dist/rockcraft-pack-action/index.js | 54 ++++--------------------- src/craft-builder.ts | 21 +--------- src/rockcraft-pack.ts | 7 ---- src/tools.ts | 15 ------- tests/craft-builder.test.ts | 63 ----------------------------- tests/rockcraft-pack.test.ts | 40 ------------------ tests/tools.test.ts | 42 ------------------- 7 files changed, 8 insertions(+), 234 deletions(-) diff --git a/dist/rockcraft-pack-action/index.js b/dist/rockcraft-pack-action/index.js index efc8457..113b778 100644 --- a/dist/rockcraft-pack-action/index.js +++ b/dist/rockcraft-pack-action/index.js @@ -19811,6 +19811,12 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``); // src/rockcraft-pack-action.ts var core4 = __toESM(require_core()); +// src/craft-builder.ts +var core2 = __toESM(require_core()); +var exec3 = __toESM(require_exec()); +var fs2 = __toESM(require("fs")); +var path = __toESM(require("path")); + // src/tools.ts var core = __toESM(require_core()); var exec = __toESM(require_exec()); @@ -19825,9 +19831,6 @@ function expandHome(p) { function shellUser() { return os.userInfo().username; } -function fileExists(path2) { - return fs.existsSync(path2); -} async function haveExecutable(path2) { try { await fs.promises.access(path2, fs.constants.X_OK); @@ -19836,22 +19839,6 @@ async function haveExecutable(path2) { } return true; } -function validateArgument(value, field) { - if (value.includes(" ")) { - throw new Error(`Invalid argument '${value}' in field '${field}'`); - } -} -async function haveFlag(tool, flag) { - let output = ""; - await exec.exec("script", ["-q", "-c", `${tool} pack -h`], { - silent: true, - listeners: { stdout: (data) => output += data.toString() } - }); - return output.includes(flag); -} -async function haveSubcommand(tool, subcommand) { - return await exec.exec(tool, [subcommand, "-h"], { ignoreReturnCode: true }) === 0; -} async function ensureSnapd() { const haveSnapd = await haveExecutable("/usr/bin/snap"); if (!haveSnapd) { @@ -19941,10 +19928,6 @@ async function ensureCraftTool(name, channel, revision) { } // src/craft-builder.ts -var core2 = __toESM(require_core()); -var exec3 = __toESM(require_exec()); -var fs2 = __toESM(require("fs")); -var path = __toESM(require("path")); var CraftBuilder = class { projectRoot; channel; @@ -19963,10 +19946,6 @@ var CraftBuilder = class { async buildPackArgs() { const args = []; if (this.pro) { - validateArgument(this.pro, "pro"); - if (!await haveFlag(this.toolName, "--pro")) { - throw new Error(`This ${this.toolName} version does not support --pro.`); - } args.push(`--pro=${this.pro}`); } if (this.verbosity) { @@ -19989,26 +19968,13 @@ var CraftBuilder = class { { cwd: this.projectRoot } ); } - async resolvePackSubcommand() { - if (!this.runTests) return "pack"; - const testFile = `${this.projectRoot}/spread.yaml`; - if (!fileExists(testFile)) { - throw new Error(`Cannot run tests. Missing ${testFile} file.`); - } - if (!await haveSubcommand(this.toolName, "test")) { - throw new Error( - `Cannot run tests. ${this.toolName} test is not a valid command.` - ); - } - return "test"; - } async pack() { core2.startGroup(`Installing ${this.toolName} plus dependencies`); await ensureSnapd(); await ensureLXD(!!this.pro); await ensureCraftTool(this.toolName, this.channel, this.revision); core2.endGroup(); - await this.doPack(await this.resolvePackSubcommand()); + await this.doPack(this.runTests ? "test" : "pack"); } async #readdir(dir) { return await fs2.promises.readdir(dir); @@ -20035,12 +20001,6 @@ var RockcraftBuilder = class extends CraftBuilder { async buildPackArgs() { const args = await super.buildPackArgs(); if (this.ignore) { - validateArgument(this.ignore, "ignore"); - if (!await haveFlag(this.toolName, "--ignore")) { - throw new Error( - `This ${this.toolName} version does not support --ignore.` - ); - } args.push(`--ignore=${this.ignore}`); } return args; diff --git a/src/craft-builder.ts b/src/craft-builder.ts index f4f40ee..fcdd1b6 100644 --- a/src/craft-builder.ts +++ b/src/craft-builder.ts @@ -39,10 +39,6 @@ export abstract class CraftBuilder { const args: string[] = [] if (this.pro) { - tools.validateArgument(this.pro, 'pro') - if (!(await tools.haveFlag(this.toolName, '--pro'))) { - throw new Error(`This ${this.toolName} version does not support --pro.`) - } args.push(`--pro=${this.pro}`) } @@ -69,28 +65,13 @@ export abstract class CraftBuilder { ) } - private async resolvePackSubcommand(): Promise<'pack' | 'test'> { - if (!this.runTests) return 'pack' - - const testFile = `${this.projectRoot}/spread.yaml` - if (!tools.fileExists(testFile)) { - throw new Error(`Cannot run tests. Missing ${testFile} file.`) - } - if (!(await tools.haveSubcommand(this.toolName, 'test'))) { - throw new Error( - `Cannot run tests. ${this.toolName} test is not a valid command.` - ) - } - return 'test' - } - async pack(): Promise { core.startGroup(`Installing ${this.toolName} plus dependencies`) await tools.ensureSnapd() await tools.ensureLXD(!!this.pro) await tools.ensureCraftTool(this.toolName, this.channel, this.revision) core.endGroup() - await this.doPack(await this.resolvePackSubcommand()) + await this.doPack(this.runTests ? 'test' : 'pack') } async #readdir(dir: string): Promise { diff --git a/src/rockcraft-pack.ts b/src/rockcraft-pack.ts index 74987c0..70adca4 100644 --- a/src/rockcraft-pack.ts +++ b/src/rockcraft-pack.ts @@ -1,6 +1,5 @@ // -*- mode: javascript; js-indent-level: 2 -*- -import * as tools from './tools' import {CraftBuilder, CraftBuilderOptions} from './craft-builder' export interface RockcraftBuilderOptions extends CraftBuilderOptions { @@ -21,12 +20,6 @@ export class RockcraftBuilder extends CraftBuilder { const args = await super.buildPackArgs() if (this.ignore) { - tools.validateArgument(this.ignore, 'ignore') - if (!(await tools.haveFlag(this.toolName, '--ignore'))) { - throw new Error( - `This ${this.toolName} version does not support --ignore.` - ) - } args.push(`--ignore=${this.ignore}`) } diff --git a/src/tools.ts b/src/tools.ts index 7a31604..f82bafe 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -29,21 +29,6 @@ async function haveExecutable(path: string): Promise { return true } -export function validateArgument(value: string, field: string): void { - if (value.includes(' ')) { - throw new Error(`Invalid argument '${value}' in field '${field}'`) - } -} - -export async function haveFlag(tool: string, flag: string): Promise { - let output = '' - await exec.exec('script', ['-q', '-c', `${tool} pack -h`], { - silent: true, - listeners: {stdout: data => (output += data.toString())} - }) - return output.includes(flag) -} - export async function haveSubcommand( tool: string, subcommand: string diff --git a/tests/craft-builder.test.ts b/tests/craft-builder.test.ts index 700ff97..7618a91 100644 --- a/tests/craft-builder.test.ts +++ b/tests/craft-builder.test.ts @@ -107,9 +107,6 @@ test('CraftBuilder.pack calls ensureLXD with pro when pro is set', async () => { expect.assertions(1) const {ensureLXD} = mockSetup() - jest - .spyOn(tools, 'haveFlag') - .mockImplementation(async (): Promise => true) await makeBuilder({pro: 'esm-apps'}).pack() @@ -134,10 +131,6 @@ test('CraftBuilder.pack executes test subcommand when runTests is true', async ( expect.assertions(1) const {execMock} = mockSetup() - jest.spyOn(tools, 'fileExists').mockReturnValue(true) - jest - .spyOn(tools, 'haveSubcommand') - .mockImplementation(async (): Promise => true) await makeBuilder({projectRoot: 'my-dir', runTests: true}).pack() @@ -148,31 +141,6 @@ test('CraftBuilder.pack executes test subcommand when runTests is true', async ( ) }) -test('CraftBuilder.pack fails when runTests is true and no spread.yaml is found', async () => { - expect.assertions(1) - - mockSetup() - jest.spyOn(tools, 'fileExists').mockReturnValue(false) - - await expect( - makeBuilder({projectRoot: 'project-root', runTests: true}).pack() - ).rejects.toThrow('Cannot run tests. Missing project-root/spread.yaml file.') -}) - -test('CraftBuilder.pack fails when runTests is true and tool has no test subcommand', async () => { - expect.assertions(1) - - mockSetup() - jest.spyOn(tools, 'fileExists').mockReturnValue(true) - jest - .spyOn(tools, 'haveSubcommand') - .mockImplementation(async (): Promise => false) - - await expect(makeBuilder({runTests: true}).pack()).rejects.toThrow( - 'Cannot run tests. test-tool test is not a valid command.' - ) -}) - test('CraftBuilder.pack includes --verbosity flag when verbosity is set', async () => { expect.assertions(1) @@ -205,9 +173,6 @@ test('CraftBuilder.pack includes --pro flag when pro is set', async () => { expect.assertions(1) const {execMock} = mockSetup() - jest - .spyOn(tools, 'haveFlag') - .mockImplementation(async (): Promise => true) await makeBuilder({pro: 'esm-apps,esm-infra'}).pack() @@ -218,34 +183,6 @@ test('CraftBuilder.pack includes --pro flag when pro is set', async () => { ) }) -test('CraftBuilder.pack fails when pro flag is not supported by tool', async () => { - expect.assertions(1) - - mockSetup() - jest - .spyOn(tools, 'haveFlag') - .mockImplementation(async (): Promise => false) - - await expect(makeBuilder({pro: 'fips-updates'}).pack()).rejects.toThrow( - 'This test-tool version does not support --pro.' - ) -}) - -test('CraftBuilder.pack fails when pro argument is invalid', async () => { - expect.assertions(1) - - mockSetup() - jest - .spyOn(tools, 'haveFlag') - .mockImplementation(async (): Promise => true) - - await expect( - makeBuilder({pro: 'fips-updates another-command'}).pack() - ).rejects.toThrow( - "Invalid argument 'fips-updates another-command' in field 'pro'" - ) -}) - test('CraftBuilder.findArtifacts throws when no matching files are found', async () => { expect.assertions(1) diff --git a/tests/rockcraft-pack.test.ts b/tests/rockcraft-pack.test.ts index c44355e..91811bd 100644 --- a/tests/rockcraft-pack.test.ts +++ b/tests/rockcraft-pack.test.ts @@ -50,9 +50,6 @@ test('RockcraftBuilder.build can ignore unmaintained', async () => { expect.assertions(1) const {execMock} = mockSetup() - jest - .spyOn(tools, 'haveFlag') - .mockImplementation(async (): Promise => true) await makeBuilder({ignore: 'unmaintained', verbosity: 'trace'}).pack() @@ -72,40 +69,3 @@ test('RockcraftBuilder.build can ignore unmaintained', async () => { ) }) -test('RockcraftBuilder.build fails if ignore flag is not supported', async () => { - expect.assertions(1) - - mockSetup() - jest - .spyOn(tools, 'haveFlag') - .mockImplementation(async (): Promise => false) - - await expect(makeBuilder({ignore: 'unmaintained'}).pack()).rejects.toThrow( - 'This rockcraft version does not support --ignore.' - ) -}) - -test('RockcraftBuilder.pack fails if test is set to true and no spread.yaml is found', async () => { - expect.assertions(1) - - mockSetup() - jest.spyOn(tools, 'fileExists').mockImplementation((): boolean => false) - - await expect( - makeBuilder({projectRoot: 'project-root', runTests: true}).pack() - ).rejects.toThrow('Cannot run tests. Missing project-root/spread.yaml file.') -}) - -test('RockcraftBuilder.pack fails if test is set to true and rockcraft test is invalid', async () => { - expect.assertions(1) - - mockSetup() - jest - .spyOn(tools, 'haveSubcommand') - .mockImplementation(async (): Promise => false) - jest.spyOn(tools, 'fileExists').mockImplementation((): boolean => true) - - await expect( - makeBuilder({projectRoot: 'project-root', runTests: true}).pack() - ).rejects.toThrow('Cannot run tests. rockcraft test is not a valid command.') -}) diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 2999388..60b900c 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -531,48 +531,6 @@ test('ensureLXDNetwork sets up iptables and warns only about installed packages' ]) }) -test('haveFlag returns true if the flag is present in the help output', async () => { - expect.assertions(2) - - const execMock = jest - .spyOn(exec, 'exec') - .mockImplementation( - async ( - program: string, - args?: string[], - options?: any - ): Promise => { - options?.listeners?.stdout?.(Buffer.from('--pro\n--other-flag')) - return 0 - } - ) - - await expect(tools.haveFlag('rockcraft', '--pro')).resolves.toBe(true) - expect(execMock).toHaveBeenCalledWith( - 'script', - ['-q', '-c', 'rockcraft pack -h'], - expect.anything() - ) -}) - -test('haveFlag returns false if the flag is absent from the help output', async () => { - expect.assertions(1) - - jest - .spyOn(exec, 'exec') - .mockImplementation( - async ( - program: string, - args?: string[], - options?: any - ): Promise => { - options?.listeners?.stdout?.(Buffer.from('--other-flag')) - return 0 - } - ) - - await expect(tools.haveFlag('rockcraft', '--pro')).resolves.toBe(false) -}) test('haveSubcommand returns true if the subcommand is available', async () => { expect.assertions(2) From 1bf38cc542d290d96d94254fcf04833208fbefff Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Mon, 22 Jun 2026 16:27:19 -0400 Subject: [PATCH 10/11] chore: merge rockcraft-pack-action into rockcraft-pack --- .../index.js | 74 +++++++++++-------- rockcraft-pack/action.yml | 8 +- scripts/pack.mjs | 2 +- src/rockcraft-pack-action.ts | 12 --- src/rockcraft-pack.ts | 11 +++ 5 files changed, 60 insertions(+), 47 deletions(-) rename dist/{rockcraft-pack-action => rockcraft-pack}/index.js (99%) delete mode 100644 src/rockcraft-pack-action.ts diff --git a/dist/rockcraft-pack-action/index.js b/dist/rockcraft-pack/index.js similarity index 99% rename from dist/rockcraft-pack-action/index.js rename to dist/rockcraft-pack/index.js index 113b778..cef4f6a 100644 --- a/dist/rockcraft-pack-action/index.js +++ b/dist/rockcraft-pack/index.js @@ -8,6 +8,10 @@ var __hasOwnProp = Object.prototype.hasOwnProperty; var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) @@ -24,6 +28,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // node_modules/@actions/core/lib/utils.js var require_utils = __commonJS({ @@ -19808,7 +19813,12 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``); } }); -// src/rockcraft-pack-action.ts +// src/rockcraft-pack.ts +var rockcraft_pack_exports = {}; +__export(rockcraft_pack_exports, { + RockcraftBuilder: () => RockcraftBuilder +}); +module.exports = __toCommonJS(rockcraft_pack_exports); var core4 = __toESM(require_core()); // src/craft-builder.ts @@ -19989,24 +19999,6 @@ var CraftBuilder = class { } }; -// src/rockcraft-pack.ts -var RockcraftBuilder = class extends CraftBuilder { - toolName = "rockcraft"; - artifactType = ".rock"; - ignore; - constructor(options) { - super(options); - this.ignore = options.ignore; - } - async buildPackArgs() { - const args = await super.buildPackArgs(); - if (this.ignore) { - args.push(`--ignore=${this.ignore}`); - } - return args; - } -}; - // src/pack-action.ts var core3 = __toESM(require_core()); function readBaseInputs(channelInput = "channel") { @@ -20019,18 +20011,18 @@ function readBaseInputs(channelInput = "channel") { runTests: core3.getInput("test").toLowerCase() === "true" }; } -async function runPackAction(builder2, outputName) { +async function runPackAction(builder, outputName) { try { - if (!builder2.revision) { + if (!builder.revision) { core3.info( - `${builder2.toolName} revision not provided. Installing from ${builder2.channel}` + `${builder.toolName} revision not provided. Installing from ${builder.channel}` ); } - await builder2.pack(); - const artifacts = await builder2.findArtifacts(builder2.artifactType); + await builder.pack(); + const artifacts = await builder.findArtifacts(builder.artifactType); if (artifacts.length > 1) { core3.warning( - `Multiple ${builder2.artifactType} files found in ${builder2.projectRoot}` + `Multiple ${builder.artifactType} files found in ${builder.projectRoot}` ); } core3.setOutput(outputName, artifacts[0]); @@ -20039,12 +20031,34 @@ async function runPackAction(builder2, outputName) { } } -// src/rockcraft-pack-action.ts -var builder = new RockcraftBuilder({ - ...readBaseInputs("rockcraft-channel"), - ignore: core4.getInput("ignore") +// src/rockcraft-pack.ts +var RockcraftBuilder = class extends CraftBuilder { + toolName = "rockcraft"; + artifactType = ".rock"; + ignore; + constructor(options) { + super(options); + this.ignore = options.ignore; + } + async buildPackArgs() { + const args = await super.buildPackArgs(); + if (this.ignore) { + args.push(`--ignore=${this.ignore}`); + } + return args; + } +}; +if (require.main === module) { + const builder = new RockcraftBuilder({ + ...readBaseInputs("rockcraft-channel"), + ignore: core4.getInput("ignore") + }); + void runPackAction(builder, "rock"); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + RockcraftBuilder }); -void runPackAction(builder, "rock"); /*! Bundled license information: undici/lib/fetch/body.js: diff --git a/rockcraft-pack/action.yml b/rockcraft-pack/action.yml index ff944e9..9d1b72a 100644 --- a/rockcraft-pack/action.yml +++ b/rockcraft-pack/action.yml @@ -1,6 +1,6 @@ -name: "Build a rock" -description: "Pack an OCI image with Rockcraft" -author: "Canonical" +name: 'Build a rock' +description: 'Pack an OCI image with Rockcraft' +author: 'Canonical' inputs: path: description: > @@ -52,7 +52,7 @@ outputs: description: 'The file name of the resulting rock.' runs: using: 'node24' - main: '../dist/rockcraft-pack-action/index.js' + main: '../dist/rockcraft-pack/index.js' branding: icon: layers color: orange diff --git a/scripts/pack.mjs b/scripts/pack.mjs index 8475e17..c6a76cf 100644 --- a/scripts/pack.mjs +++ b/scripts/pack.mjs @@ -2,7 +2,7 @@ import {build} from 'esbuild' -const entryPoints = ['src/rockcraft-pack-action.ts'] +const entryPoints = ['src/rockcraft-pack.ts'] await build({ entryPoints, diff --git a/src/rockcraft-pack-action.ts b/src/rockcraft-pack-action.ts deleted file mode 100644 index e972106..0000000 --- a/src/rockcraft-pack-action.ts +++ /dev/null @@ -1,12 +0,0 @@ -// -*- mode: javascript; js-indent-level: 2 -*- - -import * as core from '@actions/core' -import {RockcraftBuilder} from './rockcraft-pack' -import {readBaseInputs, runPackAction} from './pack-action' - -const builder = new RockcraftBuilder({ - ...readBaseInputs('rockcraft-channel'), - ignore: core.getInput('ignore') -}) - -void runPackAction(builder, 'rock') diff --git a/src/rockcraft-pack.ts b/src/rockcraft-pack.ts index 70adca4..1e17be7 100644 --- a/src/rockcraft-pack.ts +++ b/src/rockcraft-pack.ts @@ -1,6 +1,8 @@ // -*- mode: javascript; js-indent-level: 2 -*- +import * as core from '@actions/core' import {CraftBuilder, CraftBuilderOptions} from './craft-builder' +import {readBaseInputs, runPackAction} from './pack-action' export interface RockcraftBuilderOptions extends CraftBuilderOptions { ignore: string @@ -26,3 +28,12 @@ export class RockcraftBuilder extends CraftBuilder { return args } } + +if (require.main === module) { + const builder = new RockcraftBuilder({ + ...readBaseInputs('rockcraft-channel'), + ignore: core.getInput('ignore') + }) + + void runPackAction(builder, 'rock') +} From 79936ca3910be6bf1fff293ca3c119e11ee0d221 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Mon, 22 Jun 2026 16:31:31 -0400 Subject: [PATCH 11/11] style: run autoformatter --- tests/rockcraft-pack.test.ts | 1 - tests/tools.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/rockcraft-pack.test.ts b/tests/rockcraft-pack.test.ts index 91811bd..553cb83 100644 --- a/tests/rockcraft-pack.test.ts +++ b/tests/rockcraft-pack.test.ts @@ -68,4 +68,3 @@ test('RockcraftBuilder.build can ignore unmaintained', async () => { {cwd: '.'} ) }) - diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 60b900c..ee32dcb 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -531,7 +531,6 @@ test('ensureLXDNetwork sets up iptables and warns only about installed packages' ]) }) - test('haveSubcommand returns true if the subcommand is available', async () => { expect.assertions(2)