From 494208d0a8e029eba4a69d77625f4919b296b54f Mon Sep 17 00:00:00 2001 From: goeselt Date: Tue, 23 Jun 2026 10:31:32 +0200 Subject: [PATCH 1/2] fix: add tag verification and signing checks for releases --- docs/integration-guide.md | 19 +++++++++-- github-api.js | 10 +++++- github-api.test.js | 25 +++++++++++++++ release.js | 45 +++++++++++++++++++++++++- release.test.js | 66 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 5f7817a..88e3687 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -120,8 +120,23 @@ with `git verify-tag ` after importing your public key. ``` If `signing-key` is set and the concrete release tag already exists, dispatch verifies the existing tag with -`git verify-tag` before reusing it. The key is imported into a temporary `GNUPGHOME` pinned to the imported key -fingerprint; the temporary keyring is removed before the action exits. +`git verify-tag` before reusing it. The key is imported into a temporary `GNUPGHOME`, used per `git tag -s` invocation, +and removed before the action exits; nothing is written to the checkout's `.git/config`. + +After creating a signed tag, dispatch queries the host's signature verification and warns (without rolling back the +release) if it reads as unverified. The most common reason is `no_user`: the tag is correctly signed, but the tagger +identity does not match the signing key, so the host cannot attribute it. To get a verified badge, set `git-user-name` +and `git-user-email` to an identity associated with the key, and register the key's public part with the account that +owns the release: + +```yaml +- uses: goeselt/dispatch@v1 + with: + release-tag: ${{ steps.version.outputs.release-tag }} + signing-key: ${{ secrets.RELEASE_SIGNING_KEY }} + git-user-name: ${{ vars.RELEASE_SIGNING_USER }} + git-user-email: ${{ vars.RELEASE_SIGNING_EMAIL }} +``` See [Signing Key Setup](signing-key.md) for a Debian-based setup guide. diff --git a/github-api.js b/github-api.js index ea14b29..9bb9def 100644 --- a/github-api.js +++ b/github-api.js @@ -248,7 +248,15 @@ function createClient(token, requestOptions = {}) { return release.html_url || '' } - return { checkAuth, getReleaseByTag, createRelease } + // getTagVerification returns the host's signature verification for an annotated tag object, e.g. + // { verified: false, reason: 'no_user' }. tagSha is the tag object OID (`git rev-parse ^{tag}`). It lets the + // caller report whether a signed tag will show as verified, without changing the release. + async function getTagVerification(repo, tagSha) { + const { body } = await call('GET', `${apiBaseUrl()}/repos/${repoPath(repo)}/git/tags/${encodeURIComponent(tagSha)}`) + return body.verification || {} + } + + return { checkAuth, getReleaseByTag, createRelease, getTagVerification } } module.exports = { diff --git a/github-api.test.js b/github-api.test.js index c8beabd..66636b6 100644 --- a/github-api.test.js +++ b/github-api.test.js @@ -182,6 +182,31 @@ test('createRelease without assets publishes directly and returns the URL', asyn ) }) +test('getTagVerification returns the host verification for a tag object', async () => { + await withApiUrl('https://api.github.com', () => + withFetch( + () => + fakeResponse({ status: 200, body: { tag: 'v1.2.3', verification: { verified: false, reason: 'no_user' } } }), + async (calls) => { + const verification = await createClient('secret').getTagVerification('owner/name', 'deadbeef') + assert.deepEqual(verification, { verified: false, reason: 'no_user' }) + assert.equal(calls[0].url, 'https://api.github.com/repos/owner/name/git/tags/deadbeef') + }, + ), + ) +}) + +test('getTagVerification returns an empty object when the response has no verification', async () => { + await withApiUrl('https://api.github.com', () => + withFetch( + () => fakeResponse({ status: 200, body: { tag: 'v1.2.3' } }), + async () => { + assert.deepEqual(await createClient('secret').getTagVerification('owner/name', 'deadbeef'), {}) + }, + ), + ) +}) + test('createRelease with assets drafts, uploads, then publishes', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-api-')) const assetPath = path.join(dir, 'artifact.bin') diff --git a/release.js b/release.js index f4c27e9..6e0e8e8 100644 --- a/release.js +++ b/release.js @@ -147,6 +147,19 @@ function tagExists(exec, tag) { return remoteTagObjectId(exec, tag) !== '' } +// assertTagSigned fails loudly if signing was requested but the freshly created tag carries no GPG signature, instead +// of pushing and releasing an unsigned tag. `git tag -s` already aborts when gpg cannot sign, so this is a cheap +// belt-and-suspenders check; its main value is making the signed state explicit and verifiable in the action log, +// which a "Verified" badge on the host UI -- which depends on the public key being registered there -- does not. +function assertTagSigned(exec, tag) { + const body = exec('git', ['cat-file', '-p', tag]).stdout + if (!body.includes('-----BEGIN PGP SIGNATURE-----')) { + throw new Error( + `signing-key was set but tag ${tag} has no GPG signature after creation. Check that the key is valid and that gpg can sign non-interactively on this runner.`, + ) + } +} + function createTag(exec, tag, sign = false, identityArgs = []) { validateTagName(tag) // -s makes a GPG-signed tag; -a an unsigned annotated one. We pass the flag explicitly rather than rely on the @@ -154,6 +167,7 @@ function createTag(exec, tag, sign = false, identityArgs = []) { // config per invocation so nothing is written to .git/config. exec('git', [...identityArgs, 'tag', sign ? '-s' : '-a', tag, '-m', `Release ${tag}`]) try { + if (sign) assertTagSigned(exec, tag) exec('git', ['push', '--no-verify', 'origin', `refs/tags/${tag}:refs/tags/${tag}`]) } catch (err) { exec('git', ['tag', '-d', tag], { allowFailure: true }) @@ -182,6 +196,7 @@ function updateFloatingTag(exec, floatingTag, releaseTag, sign = false, identity '-m', `Floating tag for ${releaseTag}`, ]) + if (sign) assertTagSigned(exec, floatingTag) exec('git', [ 'push', '--no-verify', @@ -194,6 +209,30 @@ function updateFloatingTag(exec, floatingTag, releaseTag, sign = false, identity // Release orchestration +// reportTagVerification asks the host whether the freshly signed tag verifies and surfaces the result. A correctly +// signed tag can still be reported unverified -- most commonly reason "no_user", when the tagger identity does not match +// the signing key's account. We warn but never roll back: the release is valid, only the verification badge is affected. +// The check is best-effort; failing to query it is logged, not fatal. +async function reportTagVerification(exec, api, repo, tag) { + let verification + try { + const tagSha = exec('git', ['rev-parse', `${tag}^{tag}`]).stdout.trim() + verification = await api.getTagVerification(repo, tagSha) + } catch (err) { + logInfo(`could not check signature verification for ${tag}: ${err.message}`) + return + } + if (verification.verified) { + logInfo(`signature on ${tag} verified by the host`) + return + } + const reason = verification.reason || 'unknown' + logInfo(`signature on ${tag} is present but the host did not verify it (reason: ${reason})`) + writeWarning( + `tag ${tag} is signed but the host reports it as unverified (reason: ${reason}). The tagger identity likely does not match the signing key; set git-user-name/git-user-email to an identity on the key and register the key's public part with the signing account.`, + ) +} + // runRelease orchestrates the release. Git operations run synchronously through exec; the GitHub REST operations // (auth check, release lookup, release creation with asset upload) run through the injected async api client, so the // whole function is async. @@ -241,11 +280,15 @@ async function runRelease(inputs, exec, api, cwd = process.cwd()) { } else if (inputs.createTag) { createTag(releaseExec, inputs.releaseTag, sign, identityArgs) tagCreated = true - logInfo(`created and pushed ${sign ? 'signed ' : ''}tag ${inputs.releaseTag}`) + logInfo(`created and pushed tag ${inputs.releaseTag}${sign ? ' (signed)' : ''}`) } else { throw new Error(`release tag ${inputs.releaseTag} does not exist and create-tag is false`) } + // For a newly signed tag, surface whether the host could attribute the signature -- a valid signature can still + // read as unverified (e.g. reason "no_user"). This only warns; the release is kept. + if (sign && tagCreated) await reportTagVerification(releaseExec, api, repo, inputs.releaseTag) + let releaseCreated = false let releaseUrl = '' let assetsUploaded = 0 diff --git a/release.test.js b/release.test.js index 241705d..82c9c47 100644 --- a/release.test.js +++ b/release.test.js @@ -76,6 +76,12 @@ function makeApi(overrides = {}, trace = null) { : `https://github.com/org/repo/releases/tag/${tag}`, ) }, + getTagVerification: (repo, tagSha) => { + record(['getTagVerification', repo, tagSha]) + return Promise.resolve( + overrides.getTagVerification ? overrides.getTagVerification(repo, tagSha) : { verified: true }, + ) + }, } api.calls = calls api.called = (method) => calls.some((entry) => entry[0] === method) @@ -137,6 +143,10 @@ test('createTag deletes a local tag when pushing it fails', () => { // Release setup orchestration +// A tag object body as `git cat-file -p` prints it, including the GPG signature block the signed-tag self-check looks for. +const SIGNED_TAG_BODY = + 'object 0000\ntype commit\ntag t\n\nmsg\n-----BEGIN PGP SIGNATURE-----\n\nsig\n-----END PGP SIGNATURE-----\n' + test('runRelease signs tags when signing-key is provided', async () => { const previousGnupgHome = process.env.GNUPGHOME const exec = makeExec({ @@ -144,6 +154,7 @@ test('runRelease signs tags when signing-key is provided', async () => { stdout: 'sec:::::::::\nfpr:::::::::ABCDEF1234567890:\n', }, 'git\x00ls-remote\x00--tags\x00--refs\x00origin\x00refs/tags/v1.2.3': { stdout: '' }, + 'git\x00cat-file\x00-p\x00v1.2.3': { stdout: SIGNED_TAG_BODY }, }) await runRelease(inputs({ signingKey: Buffer.from('fake-gpg-key').toString('base64') }), exec, makeApi()) @@ -169,6 +180,8 @@ test('runRelease signs floating tags when signing-key is provided', async () => stdout: 'sec:::::::::\nfpr:::::::::ABCDEF1234567890:\n', }, 'git\x00ls-remote\x00--tags\x00--refs\x00origin\x00refs/tags/v1.2.3': { stdout: '' }, + 'git\x00cat-file\x00-p\x00v1.2.3': { stdout: SIGNED_TAG_BODY }, + 'git\x00cat-file\x00-p\x00v1': { stdout: SIGNED_TAG_BODY }, }) await runRelease( @@ -194,6 +207,59 @@ test('runRelease signs floating tags when signing-key is provided', async () => ) }) +test('runRelease fails loudly when signing was requested but the created tag is not signed', async () => { + const exec = makeExec({ + 'gpg\x00--batch\x00--list-secret-keys\x00--with-colons\x00--fingerprint': { + stdout: 'sec:::::::::\nfpr:::::::::ABCDEF1234567890:\n', + }, + 'git\x00ls-remote\x00--tags\x00--refs\x00origin\x00refs/tags/v1.2.3': { stdout: '' }, + // The tag object carries no signature block, so the self-check must reject rather than push an unsigned tag. + 'git\x00cat-file\x00-p\x00v1.2.3': { stdout: 'object 0000\ntype commit\ntag t\n\nmsg\n' }, + }) + + await assert.rejects( + runRelease(inputs({ signingKey: Buffer.from('fake-gpg-key').toString('base64') }), exec, makeApi()), + /no GPG signature/, + ) + assert.equal( + exec.calls.some((c) => c[0] === 'git' && c[1] === 'push'), + false, + 'an unsigned tag must not be pushed', + ) +}) + +test('runRelease warns but keeps the release when a signed tag is reported unverified', async () => { + const exec = makeExec({ + 'gpg\x00--batch\x00--list-secret-keys\x00--with-colons\x00--fingerprint': { + stdout: 'sec:::::::::\nfpr:::::::::ABCDEF1234567890:\n', + }, + 'git\x00ls-remote\x00--tags\x00--refs\x00origin\x00refs/tags/v1.2.3': { stdout: '' }, + 'git\x00cat-file\x00-p\x00v1.2.3': { stdout: SIGNED_TAG_BODY }, + 'git\x00rev-parse\x00v1.2.3^{tag}': { stdout: 'deadbeefcafe\n' }, + }) + const api = makeApi({ getTagVerification: () => ({ verified: false, reason: 'no_user' }) }) + + const written = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = (msg) => { + written.push(msg) + return true + } + let result + try { + result = await runRelease(inputs({ signingKey: Buffer.from('fake-gpg-key').toString('base64') }), exec, api) + } finally { + process.stdout.write = origWrite + } + + // The tag and release are kept -- a no_user signature is a warning, never a rollback. + assert.equal(result.tagCreated, true) + assert.equal(result.releaseCreated, true) + assert.equal(exec.called('git', 'tag', '-d', 'v1.2.3'), false, 'a published tag must not be deleted') + assert.deepEqual(api.callFor('getTagVerification'), ['getTagVerification', undefined, 'deadbeefcafe']) + assert.match(written.join(''), /::warning.*unverified.*no_user/) +}) + test('runRelease applies the git identity per invocation and never writes to git config', async () => { const exec = makeExec({ 'git\x00ls-remote\x00--tags\x00--refs\x00origin\x00refs/tags/v1.2.3': { stdout: '' }, From c782790ec3be1b41fe84eccf34303b0c1ba06d6b Mon Sep 17 00:00:00 2001 From: goeselt Date: Tue, 23 Jun 2026 11:49:28 +0200 Subject: [PATCH 2/2] fix: enhance tag signing verification and update README inputs --- CONTRIBUTING.md | 7 +++++++ README.md | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4649c8..984eeb2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,6 +79,13 @@ tags are created with an explicit `git tag -s` (not the git-version-dependent `t the imported fingerprint passed via `-c user.signingkey=`. The key is imported into a temporary `GNUPGHOME` that is removed before the action exits. +Signing integrity is checked at two points. Before pushing, `assertTagSigned` reads the tag object with +`git cat-file -p` and throws if no `BEGIN PGP SIGNATURE` block is present; this catches config mismatches before +anything reaches the remote. After the push and release creation, `reportTagVerification` queries the host's signature +verification endpoint (`GET /git/tags/{sha}`) and emits a `::warning` if the tag is reported as unverified. The most +common reason is `no_user`: the tag is correctly signed but the tagger identity does not match the signing key's +registered account. The release is never rolled back in this case -- `reportTagVerification` is informational only. + REST release calls are bound to `GITHUB_REPOSITORY`, and the release auth check (`GET /repos/{owner}/{repo}`) happens before the concrete tag is created or pushed. diff --git a/README.md b/README.md index 368d82a..5936f5b 100644 --- a/README.md +++ b/README.md @@ -61,20 +61,20 @@ whitespace, control characters, refspec syntax, `..`, or option-like values begi When provided, `major-tag` and `minor-tag` must match the semantic `release-tag`. For `v1.2.3`, the only matching floating tags are `v1` and `v1.2`. -| Input | Default | Description | -| -------------------------- | ---------------- | ----------------------------------------------------------------------------------------------- | -| `release-tag` | | Release tag name, for example `v1.2.3`. **Required.** | -| `create-tag` | `true` | Create and push the release tag when it does not already exist. | -| `create-release` | `true` | Create the GitHub Release. | -| `allow-non-default-branch` | `false` | Allow releases from a non-default branch. PR events and tag refs remain blocked. | -| `make-latest` | `default-branch` | Controls GitHub's Latest marker: `default-branch`, `auto`, `true`, or `false`. | -| `signing-key` | | Base64-encoded GPG private key. When set, all annotated tags created by this action are signed. | -| `assets` | | Newline-separated asset files or glob patterns to upload. Paths must exist; globs must match. | -| `major-tag` | | Floating major tag to update, e.g. `v1`. | -| `minor-tag` | | Floating minor tag to update, e.g. `v1.2`. | -| `github-token` | token | GitHub token used by `gh` and temporary Git auth for tag fetch/push operations. | -| `git-user-name` | actor | `git user.name` for annotated tags. | -| `git-user-email` | actor | `git user.email` for annotated tags. | +| Input | Default | Description | +| -------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------- | +| `release-tag` | | Release tag name, for example `v1.2.3`. **Required.** | +| `create-tag` | `true` | Create and push the release tag when it does not already exist. | +| `create-release` | `true` | Create the GitHub Release. | +| `allow-non-default-branch` | `false` | Allow releases from a non-default branch. PR events and tag refs remain blocked. | +| `make-latest` | `default-branch` | Controls GitHub's Latest marker: `default-branch`, `auto`, `true`, or `false`. | +| `signing-key` | | Base64-encoded GPG private key. When set, all annotated tags created by this action are signed. | +| `assets` | | Newline-separated asset files or glob patterns to upload. Paths must exist; globs must match. | +| `major-tag` | | Floating major tag to update, e.g. `v1`. | +| `minor-tag` | | Floating minor tag to update, e.g. `v1.2`. | +| `github-token` | token | GitHub token for GitHub REST API calls and Git tag fetch/push operations. | +| `git-user-name` | actor | `git user.name` for annotated tags. | +| `git-user-email` | | `git user.email` for annotated tags. | ## Outputs