Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | <actor@users.noreply.github.com> | `git user.email` for annotated tags. |

## Outputs

Expand Down
19 changes: 17 additions & 2 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,23 @@ with `git verify-tag <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.

Expand Down
10 changes: 9 additions & 1 deletion github-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>^{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 = {
Expand Down
25 changes: 25 additions & 0 deletions github-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
45 changes: 44 additions & 1 deletion release.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,27 @@ 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
// tag.gpgsign config, whose effect on an explicit -a is git-version-dependent. identityArgs carry the tagger/signing
// 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 })
Expand Down Expand Up @@ -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',
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions release.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -137,13 +143,18 @@ 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({
'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 },
})

await runRelease(inputs({ signingKey: Buffer.from('fake-gpg-key').toString('base64') }), exec, makeApi())
Expand All @@ -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(
Expand All @@ -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: '' },
Expand Down