Skip to content

Commit 4b462d3

Browse files
caarlos0Copilot
andauthored
feat: verify release checksum and cosign signature (#550)
* feat: verify release checksum and cosign signature Download checksums.txt for the release and verify the SHA-256 of the downloaded archive against it. When cosign is available in PATH, also download checksums.txt.sigstore.json and verify the signature against the goreleaser/goreleaser-pro release workflow identity. Both steps degrade gracefully (with a warning) when the corresponding artifacts or tooling are missing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: use install() for checksum e2e tests Drop the http-client download helper from verifyChecksum integration tests; call goreleaser.install() instead so the test exercises the public API path and avoids duplicating download logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 01cbe07 commit 4b462d3

File tree

5 files changed

+199
-13
lines changed

5 files changed

+199
-13
lines changed

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ jobs:
7979
distribution:
8080
- goreleaser
8181
- goreleaser-pro
82+
cosign:
83+
- true
84+
- false
8285
steps:
8386
-
8487
name: Checkout
@@ -90,6 +93,10 @@ jobs:
9093
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
9194
with:
9295
go-version: 1.18
96+
-
97+
name: Install cosign
98+
if: matrix.cosign
99+
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
93100
-
94101
name: GoReleaser
95102
if: ${{ !(github.event_name == 'pull_request' && matrix.distribution == 'goreleaser-pro') }}

__tests__/goreleaser.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import {describe, expect, it} from '@jest/globals';
22
import * as fs from 'fs';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
import * as io from '@actions/io';
36
import * as goreleaser from '../src/goreleaser';
47

58
describe('install', () => {
@@ -53,3 +56,92 @@ describe('distribSuffix', () => {
5356
expect(goreleaser.distribSuffix('goreleaser')).toEqual('');
5457
});
5558
});
59+
60+
describe('findChecksum', () => {
61+
const sample = [
62+
'*malformed-line',
63+
'',
64+
'abc123 goreleaser_Linux_x86_64.tar.gz',
65+
'def456 *goreleaser_Darwin_all.tar.gz',
66+
'789xyz checksums.txt'
67+
].join('\n');
68+
69+
it('finds a checksum by filename', () => {
70+
expect(goreleaser.findChecksum(sample, 'goreleaser_Linux_x86_64.tar.gz')).toEqual('abc123');
71+
});
72+
73+
it('strips a leading asterisk on the filename (binary mode)', () => {
74+
expect(goreleaser.findChecksum(sample, 'goreleaser_Darwin_all.tar.gz')).toEqual('def456');
75+
});
76+
77+
it('returns undefined when not present', () => {
78+
expect(goreleaser.findChecksum(sample, 'missing.tar.gz')).toBeUndefined();
79+
});
80+
});
81+
82+
describe('getCertificateIdentity', () => {
83+
it('returns the OSS workflow identity for tagged releases', () => {
84+
expect(goreleaser.getCertificateIdentity('goreleaser', 'v2.15.3')).toEqual(
85+
'https://github.com/goreleaser/goreleaser/.github/workflows/release.yml@refs/tags/v2.15.3'
86+
);
87+
});
88+
89+
it('returns the Pro internal workflow identity for tagged releases', () => {
90+
expect(goreleaser.getCertificateIdentity('goreleaser-pro', 'v2.15.3')).toEqual(
91+
'https://github.com/goreleaser/goreleaser-pro-internal/.github/workflows/release-pro.yml@refs/tags/v2.15.3'
92+
);
93+
});
94+
95+
it('uses nightly-oss.yml@refs/heads/main for OSS nightly', () => {
96+
expect(goreleaser.getCertificateIdentity('goreleaser', 'nightly')).toEqual(
97+
'https://github.com/goreleaser/goreleaser/.github/workflows/nightly-oss.yml@refs/heads/main'
98+
);
99+
});
100+
101+
it('uses nightly-pro.yml@refs/heads/main for Pro nightly', () => {
102+
expect(goreleaser.getCertificateIdentity('goreleaser-pro', 'nightly')).toEqual(
103+
'https://github.com/goreleaser/goreleaser-pro-internal/.github/workflows/nightly-pro.yml@refs/heads/main'
104+
);
105+
});
106+
});
107+
108+
describe('verifyChecksum', () => {
109+
const requireCosign = async (): Promise<void> => {
110+
const cosign = await io.which('cosign', false);
111+
if (!cosign) {
112+
throw new Error(
113+
'cosign must be installed in PATH to run this integration test (apk add cosign / sigstore/cosign-installer)'
114+
);
115+
}
116+
};
117+
118+
it('verifies a tagged OSS release end-to-end with cosign', async () => {
119+
await requireCosign();
120+
const bin = await goreleaser.install('goreleaser', 'v2.15.3');
121+
expect(fs.existsSync(bin)).toBe(true);
122+
}, 120000);
123+
124+
it('verifies the OSS nightly release end-to-end with cosign', async () => {
125+
await requireCosign();
126+
const bin = await goreleaser.install('goreleaser', 'nightly');
127+
expect(fs.existsSync(bin)).toBe(true);
128+
}, 120000);
129+
130+
it('throws on checksum mismatch', async () => {
131+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gha-'));
132+
const archive = path.join(dir, 'fake.tar.gz');
133+
fs.writeFileSync(archive, 'tampered content');
134+
await expect(
135+
goreleaser.verifyChecksum('goreleaser', 'v2.15.3', archive, 'goreleaser_Linux_x86_64.tar.gz')
136+
).rejects.toThrow(/Checksum mismatch/);
137+
}, 60000);
138+
139+
it('throws when the filename is not in checksums.txt', async () => {
140+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gha-'));
141+
const archive = path.join(dir, 'whatever.tar.gz');
142+
fs.writeFileSync(archive, '');
143+
await expect(
144+
goreleaser.verifyChecksum('goreleaser', 'v2.15.3', archive, 'not-a-real-asset.tar.gz')
145+
).rejects.toThrow(/Could not find not-a-real-asset.tar.gz in checksums.txt/);
146+
}, 60000);
147+
});

dev.Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ RUN --mount=type=bind,target=.,rw \
6161
npm run lint
6262

6363
FROM deps AS test
64+
RUN apk add --no-cache cosign
6465
ENV RUNNER_TEMP=/tmp/github_runner
6566
ENV RUNNER_TOOL_CACHE=/tmp/github_tool_cache
6667
RUN --mount=type=bind,target=.,rw \

dist/index.js

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/goreleaser.ts

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
1+
import * as crypto from 'crypto';
12
import * as fs from 'fs';
23
import * as path from 'path';
3-
import * as util from 'util';
44
import yaml from 'js-yaml';
55
import * as context from './context';
66
import * as github from './github';
77
import * as core from '@actions/core';
8+
import * as exec from '@actions/exec';
9+
import * as io from '@actions/io';
810
import * as tc from '@actions/tool-cache';
911

1012
export async function install(distribution: string, version: string): Promise<string> {
1113
const release: github.GitHubRelease = await github.getRelease(distribution, version);
1214
const filename = getFilename(distribution);
13-
const downloadUrl = util.format(
14-
'https://github.com/goreleaser/%s/releases/download/%s/%s',
15-
distribution,
16-
release.tag_name,
17-
filename
18-
);
15+
const baseUrl = `https://github.com/goreleaser/${distribution}/releases/download/${release.tag_name}`;
16+
const downloadUrl = `${baseUrl}/${filename}`;
1917

2018
core.info(`Downloading ${downloadUrl}`);
2119
const downloadPath: string = await tc.downloadTool(downloadUrl);
2220
core.debug(`Downloaded to ${downloadPath}`);
2321

22+
await verifyChecksum(distribution, release.tag_name, downloadPath, filename);
23+
2424
core.info('Extracting GoReleaser');
2525
let extPath: string;
2626
if (context.osPlat == 'win32') {
@@ -45,6 +45,92 @@ export async function install(distribution: string, version: string): Promise<st
4545
return exePath;
4646
}
4747

48+
export async function verifyChecksum(
49+
distribution: string,
50+
tag: string,
51+
archivePath: string,
52+
filename: string
53+
): Promise<void> {
54+
const baseUrl = `https://github.com/goreleaser/${distribution}/releases/download/${tag}`;
55+
let checksumsPath: string;
56+
try {
57+
core.info(`Downloading ${baseUrl}/checksums.txt`);
58+
checksumsPath = await tc.downloadTool(`${baseUrl}/checksums.txt`);
59+
} catch (e) {
60+
core.warning(`Skipping checksum verification: unable to download checksums.txt: ${e.message}`);
61+
return;
62+
}
63+
64+
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(archivePath)).digest('hex');
65+
const expected = findChecksum(fs.readFileSync(checksumsPath, 'utf8'), filename);
66+
if (!expected) {
67+
throw new Error(`Could not find ${filename} in checksums.txt`);
68+
}
69+
if (expected.toLowerCase() !== sha256.toLowerCase()) {
70+
throw new Error(`Checksum mismatch for ${filename}: expected ${expected}, got ${sha256}`);
71+
}
72+
core.info(`Checksum verified for ${filename}`);
73+
74+
await verifyCosignSignature(distribution, tag, baseUrl, checksumsPath);
75+
}
76+
77+
export const findChecksum = (checksumsContent: string, filename: string): string | undefined => {
78+
const match = checksumsContent
79+
.split('\n')
80+
.map(line => line.trim().split(/\s+/))
81+
.find(parts => parts.length >= 2 && parts[1].replace(/^[*]/, '') === filename);
82+
return match ? match[0] : undefined;
83+
};
84+
85+
async function verifyCosignSignature(
86+
distribution: string,
87+
tag: string,
88+
baseUrl: string,
89+
checksumsPath: string
90+
): Promise<void> {
91+
const cosign = await io.which('cosign', false);
92+
if (!cosign) {
93+
core.info('cosign not found in PATH, skipping signature verification');
94+
return;
95+
}
96+
97+
let bundlePath: string;
98+
try {
99+
core.info(`Downloading ${baseUrl}/checksums.txt.sigstore.json`);
100+
bundlePath = await tc.downloadTool(`${baseUrl}/checksums.txt.sigstore.json`);
101+
} catch (e) {
102+
core.warning(`Skipping cosign signature verification: unable to download sigstore bundle: ${e.message}`);
103+
return;
104+
}
105+
106+
const certificateIdentity = getCertificateIdentity(distribution, tag);
107+
core.info(`Verifying checksums.txt signature with cosign (identity: ${certificateIdentity})`);
108+
await exec.exec(cosign, [
109+
'verify-blob',
110+
'--certificate-identity',
111+
certificateIdentity,
112+
'--certificate-oidc-issuer',
113+
'https://token.actions.githubusercontent.com',
114+
'--bundle',
115+
bundlePath,
116+
checksumsPath
117+
]);
118+
core.info('cosign signature verified');
119+
}
120+
121+
export const getCertificateIdentity = (distribution: string, tag: string): string => {
122+
const pro = isPro(distribution);
123+
if (tag === 'nightly') {
124+
const workflow = pro ? 'nightly-pro.yml' : 'nightly-oss.yml';
125+
const repo = pro ? 'goreleaser-pro-internal' : 'goreleaser';
126+
return `https://github.com/goreleaser/${repo}/.github/workflows/${workflow}@refs/heads/main`;
127+
}
128+
if (pro) {
129+
return `https://github.com/goreleaser/goreleaser-pro-internal/.github/workflows/release-pro.yml@refs/tags/${tag}`;
130+
}
131+
return `https://github.com/goreleaser/goreleaser/.github/workflows/release.yml@refs/tags/${tag}`;
132+
};
133+
48134
export const distribSuffix = (distribution: string): string => {
49135
return isPro(distribution) ? '-pro' : '';
50136
};
@@ -81,7 +167,7 @@ const getFilename = (distribution: string): string => {
81167
const platform: string = context.osPlat == 'win32' ? 'Windows' : context.osPlat == 'darwin' ? 'Darwin' : 'Linux';
82168
const ext: string = context.osPlat == 'win32' ? 'zip' : 'tar.gz';
83169
const suffix: string = distribSuffix(distribution);
84-
return util.format('goreleaser%s_%s_%s.%s', suffix, platform, arch, ext);
170+
return `goreleaser${suffix}_${platform}_${arch}.${ext}`;
85171
};
86172

87173
export async function getDistPath(yamlfile: string): Promise<string> {

0 commit comments

Comments
 (0)