From a27a249106dc84537792e4e041ef4bf81bcc241f Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:17:49 +0200 Subject: [PATCH 1/2] sigstore: handle cosign 3.1.1 signing defaults Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- __tests__/sigstore/sigstore.test.ts | 174 ++++++++++++++++++++++++++++ src/sigstore/sigstore.ts | 16 ++- 2 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 __tests__/sigstore/sigstore.test.ts diff --git a/__tests__/sigstore/sigstore.test.ts b/__tests__/sigstore/sigstore.test.ts new file mode 100644 index 00000000..7d10ca97 --- /dev/null +++ b/__tests__/sigstore/sigstore.test.ts @@ -0,0 +1,174 @@ +/** + * Copyright 2026 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {afterEach, beforeEach, describe, expect, it, test, vi} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as semver from 'semver'; +import {ExecOutput} from '@actions/exec'; + +import {Cosign} from '../../src/cosign/cosign.js'; +import {Exec} from '../../src/exec.js'; +import {ImageTools} from '../../src/buildx/imagetools.js'; +import {Sigstore} from '../../src/sigstore/sigstore.js'; + +const tmpDir = fs.mkdtempSync(path.join(process.env.TEMP || os.tmpdir(), 'sigstore-test-')); +const failedExecOutput: ExecOutput = { + exitCode: 1, + stdout: '', + stderr: 'cosign failed' +}; + +describe('signAttestationManifests', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://token.actions.githubusercontent.com' + }; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + test.each([ + ['v3.0.6', false, '--with-default-services=true', true, false], + ['v3.1.1', false, '--with-default-rekor-v2=true', false, false], + ['v3.1.1', true, '--with-default-services=true', false, true] + ])('given cosign %s and noTransparencyLog=%s', async (version, noTransparencyLog, expectedDefaultServiceArg, expectedNewBundleFormat, expectedNoDefaultRekor) => { + const cosign = mockCosign(version); + const sigstore = new Sigstore({ + cosign, + imageTools: mockImageTools() + }); + + const execSpy = vi.spyOn(Exec, 'exec').mockImplementation(async (_cmd, args) => { + const outArg = args?.find(arg => arg.startsWith('--out=')); + if (outArg) { + fs.writeFileSync(outArg.substring('--out='.length), '{}', 'utf-8'); + } + return 0; + }); + const execOutputSpy = vi.spyOn(Exec, 'getExecOutput').mockResolvedValue(failedExecOutput); + + await expect( + sigstore.signAttestationManifests({ + imageNames: ['example.com/foo/bar'], + imageDigest: 'sha256:manifest', + noTransparencyLog + }) + ).rejects.toThrow('Cosign sign command failed'); + + const createConfigArgs = execSpy.mock.calls[0][1] ?? []; + expect(createConfigArgs).toContain(expectedDefaultServiceArg); + expect(createConfigArgs.includes('--with-default-rekor-v2=true')).toBe(expectedDefaultServiceArg === '--with-default-rekor-v2=true'); + expect(createConfigArgs.includes('--no-default-rekor=true')).toBe(expectedNoDefaultRekor); + + const signArgs = execOutputSpy.mock.calls[0][1] ?? []; + expect(signArgs.includes('--new-bundle-format')).toBe(expectedNewBundleFormat); + expect(signArgs.some(arg => arg.startsWith('--signing-config='))).toBe(true); + }); +}); + +describe('verifyImageAttestation', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test.each([ + ['v3.0.6', true], + ['v3.1.1', false] + ])('given cosign %s', async (version, expectedNewBundleFormat) => { + const sigstore = new Sigstore({ + cosign: mockCosign(version) + }); + const execOutputSpy = vi.spyOn(Exec, 'getExecOutput').mockResolvedValue(failedExecOutput); + + await expect( + sigstore.verifyImageAttestation('example.com/foo/bar@sha256:attestation', { + certificateIdentityRegexp: '^https://github.com/docker/actions-toolkit/.*$' + }) + ).rejects.toThrow('Cosign verify command failed'); + + const verifyArgs = execOutputSpy.mock.calls[0][1] ?? []; + expect(verifyArgs).toContain('--experimental-oci11'); + expect(verifyArgs.includes('--new-bundle-format')).toBe(expectedNewBundleFormat); + }); +}); + +describe('verifySignedArtifacts', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('omits the deprecated bundle format flag for cosign 3.1.1', async () => { + const sigstore = new Sigstore({ + cosign: mockCosign('v3.1.1') + }); + const execOutputSpy = vi.spyOn(Exec, 'getExecOutput').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '' + }); + const provenancePath = path.join(tmpDir, 'provenance.json'); + const artifactPath = path.join(tmpDir, 'artifact'); + + await sigstore.verifySignedArtifacts( + { + [provenancePath]: { + bundlePath: path.join(tmpDir, 'provenance.sigstore.json'), + certificate: '', + payload: {} as never, + subjects: [ + { + name: path.basename(artifactPath), + digest: { + sha256: 'digest' + } + } + ] + } + }, + { + certificateIdentityRegexp: '^https://github.com/docker/actions-toolkit/.*$' + } + ); + + const verifyArgs = execOutputSpy.mock.calls[0][1] ?? []; + expect(verifyArgs).not.toContain('--new-bundle-format'); + expect(verifyArgs).toContain('--bundle'); + }); +}); + +function mockCosign(version: string): Cosign { + return { + binPath: 'cosign', + isAvailable: vi.fn().mockResolvedValue(true), + versionSatisfies: vi.fn(async (range: string) => { + return semver.satisfies(version, range) || /^[0-9a-f]{7}$/.exec(version) !== null; + }) + } as unknown as Cosign; +} + +function mockImageTools(): ImageTools { + return { + attestationDigests: vi.fn().mockResolvedValue(['sha256:attestation']) + } as unknown as ImageTools; +} diff --git a/src/sigstore/sigstore.ts b/src/sigstore/sigstore.ts index 895cb5b0..9e79b8c7 100644 --- a/src/sigstore/sigstore.ts +++ b/src/sigstore/sigstore.ts @@ -91,7 +91,7 @@ export class Sigstore { const createConfigArgs = [ 'signing-config', 'create', - '--with-default-services=true', + !noTransparencyLog && (await this.cosign.versionSatisfies('>=3.1.1')) ? '--with-default-rekor-v2=true' : '--with-default-services=true', `--out=${signingConfig}` ]; if (noTransparencyLog) { @@ -129,7 +129,7 @@ export class Sigstore { '--yes', '--oidc-provider', 'github-actions', '--registry-referrers-mode', 'oci-1-1', - '--new-bundle-format', + ...(await this.bundleFormatArgs()), ...cosignExtraArgs ]; core.info(`[command]${this.cosign.binPath} ${[...cosignArgs, attestationRef].join(' ')}`); @@ -219,7 +219,7 @@ export class Sigstore { const cosignArgs = [ 'verify', '--experimental-oci11', - '--new-bundle-format', + ...(await this.bundleFormatArgs()), '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', '--certificate-identity-regexp', opts.certificateIdentityRegexp ]; @@ -352,7 +352,7 @@ export class Sigstore { // prettier-ignore const cosignArgs = [ 'verify-blob-attestation', - '--new-bundle-format', + ...(await this.bundleFormatArgs()), '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', '--certificate-identity-regexp', opts.certificateIdentityRegexp, '--type', opts.predicateType ?? COSIGN_PREDICATE_SLSA_PROVENANCE_V1 @@ -530,4 +530,12 @@ export class Sigstore { } return new X509Certificate(certBytes); } + + private async bundleFormatArgs(): Promise { + // Cosign 3.1.1 makes the new bundle format the default and deprecates this flag. + if (await this.cosign.versionSatisfies('>=3.1.1')) { + return []; + } + return ['--new-bundle-format']; + } } From 3c539266c4db1aab5f92ab2a33680886e141ef09 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:04:21 +0200 Subject: [PATCH 2/2] sigstore: retry transient manifest lookups Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- __tests__/buildx/imagetools.test.ts | 42 +++++++++++++++++++++++++ __tests__/sigstore/sigstore.test.itg.ts | 6 ++-- src/buildx/imagetools.ts | 4 +-- src/sigstore/sigstore.ts | 3 +- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/__tests__/buildx/imagetools.test.ts b/__tests__/buildx/imagetools.test.ts index 4898e906..0d556895 100644 --- a/__tests__/buildx/imagetools.test.ts +++ b/__tests__/buildx/imagetools.test.ts @@ -91,6 +91,48 @@ describe('inspectManifest', () => { expect(execSpy).toHaveBeenCalledTimes(2); }); + it('retries transient manifest not found errors when requested', async () => { + vi.useFakeTimers(); + + const getCommand = vi.fn().mockResolvedValue({ + command: 'docker', + args: ['buildx', 'imagetools', 'inspect'] + }); + const buildx = {getCommand} as unknown as Buildx; + const execSpy = vi + .spyOn(Exec, 'getExecOutput') + .mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'ERROR: ghcr.io/docker/actions-toolkit-test@sha256:manifest: not found' + }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.index.v1+json', + manifests: [] + }), + stderr: '' + }); + + const inspectPromise = new ImageTools({buildx}).inspectManifest({ + name: 'docker.io/library/alpine:latest', + retryOnManifestUnknown: true, + retryLimit: 2 + }); + + await vi.runAllTimersAsync(); + + expect(await inspectPromise).toEqual({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.index.v1+json', + manifests: [] + }); + expect(getCommand).toHaveBeenCalledWith(['imagetools', 'inspect', 'docker.io/library/alpine:latest', '--format', '{{json .Manifest}}']); + expect(execSpy).toHaveBeenCalledTimes(2); + }); + it('does not retry non-manifest errors', async () => { const getCommand = vi.fn().mockResolvedValue({ command: 'docker', diff --git a/__tests__/sigstore/sigstore.test.itg.ts b/__tests__/sigstore/sigstore.test.itg.ts index f34effb3..abb0e2bf 100644 --- a/__tests__/sigstore/sigstore.test.itg.ts +++ b/__tests__/sigstore/sigstore.test.itg.ts @@ -106,12 +106,14 @@ for (const cosignVersion of signAttestationCosignVersions) { const signResults = await sigstore.signAttestationManifests({ imageNames: [imageName], - imageDigest: buildDigest! + imageDigest: buildDigest!, + retryOnManifestUnknown: true }); expect(Object.keys(signResults).length).toEqual(2); const verifyResults = await sigstore.verifySignedManifests(signResults, { - certificateIdentityRegexp: `^https://github.com/docker/actions-toolkit/.github/workflows/test.yml.*$` + certificateIdentityRegexp: `^https://github.com/docker/actions-toolkit/.github/workflows/test.yml.*$`, + retryOnManifestUnknown: true }); expect(Object.keys(verifyResults).length).toEqual(2); }, 200000); diff --git a/src/buildx/imagetools.ts b/src/buildx/imagetools.ts index ab1d677f..41963aae 100644 --- a/src/buildx/imagetools.ts +++ b/src/buildx/imagetools.ts @@ -207,7 +207,7 @@ export class ImageTools { if (!ImageTools.isManifestUnknownError(lastError.message) || attempt === retries - 1) { throw lastError; } - core.info(`buildx imagetools inspect command failed with MANIFEST_UNKNOWN, retrying attempt ${attempt + 1}/${retries}...\n${lastError.message}`); + core.info(`buildx imagetools inspect command failed with manifest not found, retrying attempt ${attempt + 1}/${retries}...\n${lastError.message}`); await new Promise(res => setTimeout(res, Math.pow(2, attempt) * 100)); } } @@ -228,6 +228,6 @@ export class ImageTools { } private static isManifestUnknownError(message: string): boolean { - return /(MANIFEST_UNKNOWN|manifest unknown|not found: not found)/i.test(message); + return /(MANIFEST_UNKNOWN|manifest unknown|not found: not found|:\s*not found)$/i.test(message); } } diff --git a/src/sigstore/sigstore.ts b/src/sigstore/sigstore.ts index 9e79b8c7..4cd59088 100644 --- a/src/sigstore/sigstore.ts +++ b/src/sigstore/sigstore.ts @@ -177,7 +177,8 @@ export class Sigstore { const verifyResult = await this.verifyImageAttestation(attestationRef, { certificateIdentityRegexp: opts.certificateIdentityRegexp, noTransparencyLog: opts.noTransparencyLog || !signedRes.tlogID, - retryOnManifestUnknown: opts.retryOnManifestUnknown + retryOnManifestUnknown: opts.retryOnManifestUnknown, + retryLimit: opts.retryLimit }); core.info(`Signature manifest verified: https://oci.dag.dev/?image=${signedRes.imageName}@${verifyResult.signatureManifestDigest}`); result[attestationRef] = verifyResult;