diff --git a/.releaserc.json b/.releaserc.json index bf918c2a51..252cbe0f76 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -35,13 +35,7 @@ ] } ], - [ - "@semantic-release/exec", - { - "prepareCmd": "pnpm release:prepare --release=${nextRelease.version} --channel='${branch.channel ?? ''}' --sha=${nextRelease.gitHead}", - "publishCmd": "pnpm release:publish --release=${nextRelease.version} --channel='${branch.channel ?? ''}' --sha=${nextRelease.gitHead}" - } - ], + "./tools/semantic-release-plugin.js", "@containerbase/semantic-release-pnpm" ], "analyzeCommits": { diff --git a/package.json b/package.json index 49ef438c6e..1935d35a6a 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,6 @@ "oxlint": "oxlint", "oxlint-fix": "oxlint --fix", "prepare": "husky", - "release:prepare": "node tools/prepare-release.js", - "release:publish": "node tools/publish-release.js", "schema": "node tools/schema.ts", "start": "tsx src/cli/index.ts", "test": "run-s 'test:*'", @@ -68,7 +66,6 @@ "@containerbase/istanbul-reports-html": "2.0.23", "@containerbase/oxlint-plugin": "1.0.3", "@containerbase/semantic-release-pnpm": "1.4.3", - "@semantic-release/exec": "7.1.0", "@tsconfig/node24": "24.0.5", "@tsconfig/strictest": "2.0.8", "@types/common-tags": "1.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd3501c74c..7199e2baf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,9 +258,6 @@ importers: '@containerbase/semantic-release-pnpm': specifier: 1.4.3 version: 1.4.3(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)) - '@semantic-release/exec': - specifier: 7.1.0 - version: 7.1.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0) '@tsconfig/node24': specifier: 24.0.5 version: 24.0.5 @@ -1160,12 +1157,6 @@ packages: resolution: {integrity: sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==} engines: {node: '>=18'} - '@semantic-release/exec@7.1.0': - resolution: {integrity: sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ==} - engines: {node: '>=20.8.1'} - peerDependencies: - semantic-release: '>=24.1.0' - '@semantic-release/github@12.0.10': resolution: {integrity: sha512-QskygvpX8hFx9UEbqJ+C46OIhaJjsysNQ8PYJPuRDc/ek5+dh8WYGD1ofUahCcGLAOmnnwZTcmCJCEXM8XxhRw==} engines: {node: ^22.14.0 || >= 24.10.0} @@ -1317,10 +1308,6 @@ packages: resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} engines: {node: '>= 20'} - aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - aggregate-error@5.1.0: resolution: {integrity: sha512-1m4/ydbOGBbHTtBYa4/ISQcqfPdZAxQLzj71wM71T8lP4ktez8lcsVolvscrujQW4CJr/LJSzoZ6mcfnqzYQ3A==} engines: {node: '>=18'} @@ -1546,10 +1533,6 @@ packages: resolution: {integrity: sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g==} engines: {node: '>=20'} - clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} - clean-stack@5.3.0: resolution: {integrity: sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==} engines: {node: '>=14.16'} @@ -2108,10 +2091,6 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} @@ -4335,18 +4314,6 @@ snapshots: '@semantic-release/error@4.0.0': {} - '@semantic-release/exec@7.1.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0)': - dependencies: - '@semantic-release/error': 4.0.0 - aggregate-error: 3.1.0 - debug: 4.4.3(supports-color@7.2.0) - execa: 9.6.1 - lodash-es: 4.18.1 - parse-json: 8.3.0 - semantic-release: 25.0.9(supports-color@7.2.0)(typescript@6.0.3) - transitivePeerDependencies: - - supports-color - '@semantic-release/github@12.0.10(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3))(supports-color@7.2.0)': dependencies: '@octokit/core': 7.0.8 @@ -4572,11 +4539,6 @@ snapshots: agent-base@9.0.0: {} - aggregate-error@3.1.0: - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - aggregate-error@5.1.0: dependencies: clean-stack: 5.3.0 @@ -4764,8 +4726,6 @@ snapshots: chunk-data@0.1.0: {} - clean-stack@2.2.0: {} - clean-stack@5.3.0: dependencies: escape-string-regexp: 5.0.0 @@ -5384,8 +5344,6 @@ snapshots: import-meta-resolve@4.2.0: {} - indent-string@4.0.0: {} - indent-string@5.0.0: {} index-to-position@1.2.0: {} diff --git a/tools/prepare-release.js b/tools/prepare-release.js deleted file mode 100644 index b247174666..0000000000 --- a/tools/prepare-release.js +++ /dev/null @@ -1,50 +0,0 @@ -import fs from 'fs/promises'; -import { Command, Option, runExit } from 'clipanion'; -import shell from 'shelljs'; -import { hashFile } from './utils.js'; - -shell.config.fatal = true; - -class PrepareCommand extends Command { - release = Option.String('-r,--release', { required: true }); - channel = Option.String('-c,--channel', { required: true }); - gitSha = Option.String('--sha'); - dryRun = Option.Boolean('-d,--dry-run'); - - async execute() { - const { channel, dryRun, gitSha, release } = this; - - shell.echo(`Preparing version: ${release} (${gitSha}, ${channel})`); - - if (dryRun) { - shell.echo('DRY-RUN: done.'); - return 0; - } - - process.env.TAG = release; - process.env.CONTAINERBASE_VERSION = release; - - if (channel) { - process.env.CHANNEL = channel.replace('maint/', ''); - } - - shell.mkdir('-p', 'bin'); - - shell.exec('pnpm build'); - - await fs.writeFile('dist/docker/usr/local/containerbase/version', release); - shell.exec(`tar -cJf ./bin/containerbase.tar.xz -C ./dist/docker .`); - - await hashFile('./bin/containerbase.tar.xz', 'sha512'); - await hashFile('./dist/cli/containerbase-cli-amd64', 'sha512'); - await hashFile('./dist/cli/containerbase-cli-arm64', 'sha512'); - - shell.exec( - 'docker buildx bake --set settings.platform=linux/amd64,linux/arm64 build', - ); - - return 0; - } -} - -void runExit(PrepareCommand); diff --git a/tools/publish-release.js b/tools/publish-release.js deleted file mode 100644 index cd4cc33891..0000000000 --- a/tools/publish-release.js +++ /dev/null @@ -1,72 +0,0 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { Command, Option, runExit } from 'clipanion'; -import shell from 'shelljs'; - -class ReleaseCommand extends Command { - release = Option.String('-r,--release', { required: true }); - channel = Option.String('-c,--channel', { required: true }); - gitSha = Option.String('--sha'); - dryRun = Option.Boolean('-d,--dry-run'); - - async execute() { - await Promise.resolve(); - const { channel, dryRun, gitSha, release } = this; - /** @type {shell.ShellString} */ - let r; - - shell.echo(`Publish version: ${release} (${gitSha}, ${channel})`); - process.env.TAG = release; - process.env.CONTAINERBASE_VERSION = release; - - if (channel) { - process.env.CHANNEL = channel.replace('maint/', ''); - } - - const tmp = await fs.mkdtemp( - path.join(os.tmpdir(), 'renovate-docker-bake-'), - ); - const metadataFile = path.join(tmp, 'metadata.json'); - - if (dryRun) { - shell.echo('DRY-RUN: done.'); - return 0; - } - - shell.echo('Pushing docker images'); - - r = shell.exec( - `docker buildx bake --set settings.platform=linux/amd64,linux/arm64 --metadata-file ${metadataFile} push`, - ); - if (r.code) { - return 1; - } - - const meta = JSON.parse(await fs.readFile(metadataFile, 'utf8')); - const digest = meta?.push?.['containerimage.digest']; - - if (!digest) { - shell.echo('Error: missing digest\n' + JSON.stringify(meta, null, 2)); - return 1; - } - - r = shell.exec( - `cosign sign --yes ${process.env.OWNER}/${process.env.FILE}@${digest}`, - ); - if (r.code) { - return 1; - } - - r = shell.exec( - `cosign sign --yes ghcr.io/${process.env.OWNER}/${process.env.FILE}@${digest}`, - ); - if (r.code) { - return 1; - } - - return 0; - } -} - -void runExit(ReleaseCommand); diff --git a/tools/semantic-release-plugin.js b/tools/semantic-release-plugin.js new file mode 100644 index 0000000000..db2571d743 --- /dev/null +++ b/tools/semantic-release-plugin.js @@ -0,0 +1,155 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execa } from 'execa'; +import { hashFile } from './utils.js'; + +/** @typedef {import('semantic-release').PrepareContext} PrepareContext */ +/** @typedef {import('semantic-release').PublishContext} PublishContext */ +/** @typedef {import('semantic-release').SuccessContext} SuccessContext */ + +/** Digest of the pushed images, set by `publish` and signed in `success`. */ +let digest = ''; + +/** + * Creates an error without stack trace, semantic-release logs plain errors + * with their whole stack and the command output is already in the log. + * @param {string} message + */ +function releaseError(message) { + const err = new Error(message); + err.stack = `${err.name}: ${err.message}`; + return err; +} + +/** + * Runs a command without throwing, its output goes straight to the log. + * @param {string} file + * @param {string[]} args + */ +function run(file, args) { + return execa(file, args, { reject: false, stdio: 'inherit' }); +} + +/** + * Runs a command and throws when it fails. + * @param {string} file + * @param {string[]} args + */ +async function exec(file, args) { + const result = await run(file, args); + if (result.failed) { + throw releaseError(result.shortMessage); + } +} + +/** Platforms of the release images. */ +const platforms = '--set=settings.platform=linux/amd64,linux/arm64'; + +/** + * Sets the env the build and the docker bake file read for the release. + * @param {PrepareContext} context + */ +function setReleaseEnv({ branch, nextRelease }) { + process.env.TAG = nextRelease.version; + process.env.CONTAINERBASE_VERSION = nextRelease.version; + + if (branch.channel) { + process.env.CHANNEL = branch.channel.replace('maint/', ''); + } +} + +/** + * Builds the cli, the release archives with their checksums and the images. + * @param {unknown} _config + * @param {PrepareContext} context + */ +export async function prepare(_config, context) { + const { branch, logger, nextRelease } = context; + logger.log( + `Preparing version: ${nextRelease.version} (${nextRelease.gitHead}, ${branch.channel ?? ''})`, + ); + setReleaseEnv(context); + + await fs.mkdir('bin', { recursive: true }); + + await exec('pnpm', ['build']); + + await fs.writeFile( + 'dist/docker/usr/local/containerbase/version', + nextRelease.version, + ); + await exec('tar', [ + '-cJf', + './bin/containerbase.tar.xz', + '-C', + './dist/docker', + '.', + ]); + + await hashFile('./bin/containerbase.tar.xz', 'sha512'); + await hashFile('./dist/cli/containerbase-cli-amd64', 'sha512'); + await hashFile('./dist/cli/containerbase-cli-arm64', 'sha512'); + + await exec('docker', ['buildx', 'bake', platforms, 'build']); +} + +/** + * Pushes the images, they are signed in `success` after all plugins published. + * @param {unknown} _config + * @param {PublishContext} context + */ +export async function publish(_config, context) { + const { branch, logger, nextRelease } = context; + logger.log( + `Publish version: ${nextRelease.version} (${nextRelease.gitHead}, ${branch.channel ?? ''})`, + ); + setReleaseEnv(context); + + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'renovate-docker-bake-')); + const metadataFile = path.join(tmp, 'metadata.json'); + + logger.log('Pushing docker images'); + await exec('docker', [ + 'buildx', + 'bake', + platforms, + '--metadata-file', + metadataFile, + 'push', + ]); + + const meta = JSON.parse(await fs.readFile(metadataFile, 'utf8')); + digest = meta?.push?.['containerimage.digest']; + + if (!digest) { + logger.error(`Bake metadata:\n${JSON.stringify(meta, null, 2)}`); + throw releaseError('Missing image digest in the bake metadata'); + } +} + +/** + * Signs the pushed images. A failed signing doesn't fail the release, it is + * reported as a GitHub Actions error annotation instead. + * @param {unknown} _config + * @param {SuccessContext} context + */ +export async function success(_config, { logger }) { + if (!digest) { + return; + } + + for (const image of [ + `${process.env.OWNER}/${process.env.FILE}`, + `ghcr.io/${process.env.OWNER}/${process.env.FILE}`, + ]) { + const result = await run('cosign', ['sign', '--yes', `${image}@${digest}`]); + if (result.failed) { + logger.error(result.shortMessage); + process.stdout.write( + // an annotation ends at the first newline + `::error title=Image signing failed::${result.shortMessage.split('\n')[0]}\n`, + ); + } + } +}