Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions __tests__/buildx/imagetools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions __tests__/sigstore/sigstore.test.itg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
174 changes: 174 additions & 0 deletions __tests__/sigstore/sigstore.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions src/buildx/imagetools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand All @@ -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);
}
}
19 changes: 14 additions & 5 deletions src/sigstore/sigstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(' ')}`);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -219,7 +220,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
];
Expand Down Expand Up @@ -352,7 +353,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
Expand Down Expand Up @@ -530,4 +531,12 @@ export class Sigstore {
}
return new X509Certificate(certBytes);
}

private async bundleFormatArgs(): Promise<string[]> {
// 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'];
}
}
Loading