Skip to content

Commit ea46ff2

Browse files
committed
ci: require signed release tag flow
1 parent 4f0c047 commit ea46ff2

10 files changed

Lines changed: 161 additions & 10 deletions

.github/CODEOWNERS

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
# Reference: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
22

33

4-
# These owners will be the default owners for everything in
5-
# the repo. Unless a later match takes precedence,they will
6-
# be requested for review when someone opens a pull request.
7-
* @bhdicaire
4+
# These owners are the default owners for everything in the repo.
5+
# Unless a later match takes precedence, they are requested for review
6+
# when someone opens a pull request.
7+
* @bhdicaire @felleg
88

9-
# In this example, @bhdicaire owns any file in the `/docs`
10-
# directory and any of its subdirectories.
11-
/docs/ @bhdicaire
9+
# Maintainers own documentation changes too.
10+
/docs/ @bhdicaire @felleg

.github/GOVERNANCE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ things simple, transparent, and welcoming.
1313
Currently, this project is maintained by:
1414

1515
- [Benoît H. Dicaire](https://github.com/bhdicaire)
16+
- [Felix Leger](https://github.com/felleg)
17+
18+
Trusted release signers are listed in [MAINTAINERS.md](MAINTAINERS.md). Release signers create Sigstore/gitsign tags for
19+
published `vX.Y.Z` releases.
1620

1721
## Decision-Making
1822

@@ -26,6 +30,9 @@ Currently, this project is maintained by:
2630
This project is currently maintained by a single individual. If the project grows and consistent contributors emerge,
2731
they may be invited to join as co-maintainers.
2832

33+
Becoming a release signer is a separate trust decision. Release signers must use phishing-resistant account protection
34+
and must be added to `.github/release-signers.json` before their release tags are trusted by upgrade tooling.
35+
2936
## Conflict Resolution
3037

3138
We encourage respectful, constructive discussions. Most discussions and decisions happen publicly on GitHub through

.github/MAINTAINERS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ contributions, managing releases, and guiding the overall direction of the proje
66
## Current Maintainer(s)
77

88
- [Benoît H. Dicaire](https://github.com/bhdicaire)
9+
- [Felix Leger](https://github.com/felleg)
10+
11+
## Trusted Release Signer(s)
12+
13+
Release signers are maintainers trusted to create Sigstore/gitsign release tags for `vX.Y.Z` releases. The pinned
14+
machine-readable signer list lives in [release-signers.json](release-signers.json).
15+
16+
- Benoît H. Dicaire — `code@Dicaire.com`
17+
- [Felix Leger](https://github.com/felleg)`felix@felixleger.com`
918

1019
## Contact
1120

.github/release-signers.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"schema_version": "1.0",
3+
"sigstore": {
4+
"certificate_oidc_issuer": "https://github.com/login/oauth",
5+
"trusted_certificate_identities": ["code@Dicaire.com", "felix@felixleger.com"]
6+
}
7+
}

.github/workflows/release-please.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ jobs:
1717
with:
1818
# Standard GITHUB_TOKEN works perfectly for basic setups
1919
token: ${{ secrets.GITHUB_TOKEN }}
20+
# Release tags are created separately by trusted maintainers with Sigstore/gitsign.
21+
skip-github-release: true

RELEASE_CHECKLIST.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ the code-repository activities close to the scripts and generated artifacts they
66
## Before release
77

88
- Confirm the worktree only contains intended release changes: `git status --short`
9+
- Confirm the release signer is listed in `.github/release-signers.json`
10+
- Confirm Git tag signing uses Sigstore/gitsign:
11+
- `git config --get gpg.format` returns `x509`
12+
- `git config --get gpg.x509.program` returns `gitsign`
13+
- `git config --get tag.gpgsign` returns `true`
914
- Review runtime registry schema changes with `docs/adr/`
1015
- Run `npm run clean`
1116
- Run `npm run check`
@@ -17,6 +22,17 @@ the code-repository activities close to the scripts and generated artifacts they
1722
- Confirm `build/v8s.json` includes both `tree` and `links[]`
1823
- Confirm `src/worker.mjs` is generated from `scripts/workers/`
1924

25+
## Release tag
26+
27+
- Merge the release-please release pull request
28+
- Pull the clean release commit locally: `git pull`
29+
- Create the signed release tag: `git tag -s vX.Y.Z -m "vX.Y.Z"`
30+
- Verify the tag with the signing identity:
31+
`gitsign verify --certificate-identity code@Dicaire.com --certificate-oidc-issuer https://github.com/login/oauth vX.Y.Z`
32+
- Push the tag only after verification: `git push origin vX.Y.Z`
33+
- Confirm GitHub release tag rules protect `refs/tags/v*` from deletion, updates, and force-pushes, including
34+
administrator bypass
35+
2036
## Runtime smoke checks
2137

2238
- Start local Worker runtime with `npm run dev`

docs/adr/0001-use-release-please-and-semantic-versioning.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ build, lint, and tests.
1717

1818
## Decision
1919

20-
Use release-please to automate release pull requests and tags from Conventional Commits.
20+
Use release-please to automate release pull requests, version bumps, changelog entries, and release notes from
21+
Conventional Commits.
22+
23+
Release tags are created separately by a trusted maintainer using Sigstore/gitsign, as described in
24+
[0015. Require signed release tags for trusted upgrades](0015-require-signed-release-tags.md). Release-please is not the
25+
final release signer.
2126

2227
Use semantic versioning for code releases:
2328

@@ -33,7 +38,7 @@ repositories should not publish vanityURLs product releases.
3338

3439
## Consequences
3540

36-
- Product releases are repeatable and tied to commit intent
41+
- Product releases are repeatable, tied to commit intent, and signed by a trusted release identity
3742
- Instance owners can refer to release notes before upgrading
3843
- Conventional Commits matter for release automation, not just readability
3944
- Formatting, lint, build, and test automation exist through npm scripts

docs/adr/0004-detach-and-upgrade-product-files.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ instance.
1818
`npm run upgrade` refreshes product-owned paths from upstream while protecting instance-owned paths such as `custom/`,
1919
`wrangler.toml`, `.dev.vars`, and `README.md`.
2020

21+
The upstream ref used for upgrade is a supply-chain trust boundary because upgrade verification can execute synced
22+
product scripts. Release trust is governed by
23+
[0015. Require signed release tags for trusted upgrades](0015-require-signed-release-tags.md).
24+
2125
The default upgrade path includes product files such as `defaults/`, `scripts/`, `package.json`, `package-lock.json`,
2226
`LICENSE`, `.npmrc`, and `.prettierignore`. It does not refresh `README.md`, because `npm run detach` replaces the
2327
upstream README with the operator-focused instance README.
@@ -42,3 +46,5 @@ defaults can reach existing instances without asking operators to rerun setup or
4246
diff with the refreshed product files
4347
- Additive default config fields can ship in `defaults/` and be inherited by existing instances without stored-config
4448
migrations
49+
- Upgrade tooling should prefer verified signed release tags over mutable branch refs before executing synced product
50+
code
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# 0015. Require signed release tags for trusted upgrades
2+
3+
Date: 2026-06-04
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
Instance operators refresh product-owned files with `npm run upgrade`. The upgrade process fetches an upstream Git ref,
10+
syncs product files such as `defaults/`, `scripts/`, and `package.json`, then can run build, test, and doctor commands
11+
from the synced product code.
12+
13+
That means an upgrade ref is a supply-chain trust boundary. If the default ref is a mutable branch such as `main`, or if
14+
an unsigned release tag can be moved or recreated, a compromised upstream account or workflow could cause an instance
15+
operator to execute unreviewed upstream code during upgrade verification.
16+
17+
Release signatures do not prove that the code is safe. They prove that a trusted release identity authorized the Git
18+
object being consumed. That is still valuable: it raises the cost of compromise, gives operators a stable provenance
19+
check before execution, and makes unauthorized or disputed release publication easier to detect.
20+
21+
## Decision
22+
23+
Use release-please to prepare release pull requests, version bumps, changelog entries, and release notes. Do not treat
24+
release-please as the final release signer.
25+
26+
After the release pull request is merged, a trusted maintainer creates and pushes the release tag with Sigstore/gitsign.
27+
Release tags use the `vX.Y.Z` format.
28+
29+
Trusted release signatures are Sigstore/gitsign signatures whose certificate identity is one of the pinned release
30+
signer identities in `.github/release-signers.json`, and whose certificate OIDC issuer is
31+
`https://github.com/login/oauth`.
32+
33+
The initial trusted release signers are:
34+
35+
- `code@Dicaire.com`
36+
- `felix@felixleger.com`
37+
38+
Protect release tags with GitHub tag rules for `refs/tags/v*`:
39+
40+
- block tag deletion
41+
- block tag updates and force-pushes
42+
- require the release tag to be created by a trusted maintainer
43+
- include administrators in the rules and avoid bypass actors
44+
45+
Future upgrade hardening should make `npm run upgrade` default to the latest verified release tag instead of `main`,
46+
support explicit `--ref vX.Y.Z` pinning, warn on mutable branch refs, verify the selected release tag before extracting
47+
or executing product files, and document `--no-check` as the high-assurance "sync, review, then run checks" path.
48+
49+
## Release Tag Procedure
50+
51+
Configure gitsign for tag signing:
52+
53+
```sh
54+
git config --global gpg.format x509
55+
git config --global gpg.x509.program gitsign
56+
git config --global tag.gpgsign true
57+
```
58+
59+
After the release pull request has merged and `main` is clean:
60+
61+
```sh
62+
git pull
63+
git tag -s vX.Y.Z -m "vX.Y.Z"
64+
gitsign verify --certificate-identity code@Dicaire.com --certificate-oidc-issuer https://github.com/login/oauth vX.Y.Z
65+
git push origin vX.Y.Z
66+
```
67+
68+
Use the signer identity that matches the maintainer creating the release. A second trusted signer may independently
69+
verify the pushed tag.
70+
71+
## Consequences
72+
73+
- Operators have a concrete provenance check before upgrade verification executes upstream product code.
74+
- Release publication depends on trusted maintainer identity rather than only GitHub workflow authority.
75+
- A compromised mutable branch is no longer an acceptable default upgrade source.
76+
- A compromised maintainer account can still sign a malicious release if its identity controls are defeated; this model
77+
is provenance and detection, not an anti-tamper guarantee.
78+
- GitHub tag protection becomes part of release operations. Some controls must be applied through GitHub repository or
79+
organization settings, or through the GitHub API with administrative credentials.
80+
- Future SLSA provenance can add build-process guarantees beyond tag identity.

scripts/workers/worker.test.mjs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,26 @@ function base64UrlBytes(bytes) {
292292
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
293293
}
294294

295+
function corruptJwtSignature(token) {
296+
const parts = token.split(".");
297+
const signature = base64UrlToBytesForTest(parts[2]);
298+
signature[0] = signature[0] ^ 0xff;
299+
return `${parts[0]}.${parts[1]}.${base64UrlBytes(signature)}`;
300+
}
301+
302+
function base64UrlToBytesForTest(value) {
303+
const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
304+
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
305+
const binary = atob(padded);
306+
const bytes = new Uint8Array(binary.length);
307+
308+
for (let index = 0; index < binary.length; index += 1) {
309+
bytes[index] = binary.charCodeAt(index);
310+
}
311+
312+
return bytes;
313+
}
314+
295315
function request(path, init = {}) {
296316
return new Request(new URL(path, "https://dicai.re"), {
297317
...init,
@@ -735,7 +755,7 @@ await run("rejects Cloudflare Access tokens with invalid JWT headers or signatur
735755
const missingKid = await signAccessJwt(fixture, { header: { kid: "" } });
736756
const wrongAlgorithm = await signAccessJwt(fixture, { header: { alg: "HS256" } });
737757
const valid = await signAccessJwt(fixture);
738-
const invalidSignature = `${valid.slice(0, -1)}${valid.endsWith("A") ? "B" : "A"}`;
758+
const invalidSignature = corruptJwtSignature(valid);
739759

740760
for (const [name, token] of [
741761
["missing kid", missingKid],

0 commit comments

Comments
 (0)