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
37 changes: 28 additions & 9 deletions .github/wiki/Releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,42 @@ the chain uses the workflow's own `GITHUB_TOKEN` plus scoped repository secrets:
| Job | Permissions | Secrets | Does |
|---|---|---|---|
| `build` | contents read | none | deterministic source and per-platform tarballs (`scripts/deterministic-tar.sh`) |
| `publish` | contents write, id-token write, attestations write | Cloudflare R2 (R2 step only) | uploads missing tarballs to the Release (never `--clobber`), re-downloads the published bytes, writes the `.sha256` files from them, mirrors those bytes to R2 and reads each object back, attests build provenance for the tarballs this run built, records the published sha256 list (also in the job summary) |
| `sign` | contents read | free-tier signing key (signing step only) | bound to the `release-signing` Environment; downloads what the Release serves, refuses it unless it equals the list `publish` recorded, signs the source tarballs; runs the tag's own commit of `sign.sh` |
| `publish` | contents write, id-token write, attestations write | Cloudflare R2 (R2 step only) | uploads missing tarballs to the Release (never `--clobber`), re-downloads the published bytes, writes the `.sha256` files from them, mirrors those bytes to R2 and reads each object back, attests build provenance for the tarballs this run built, records the published sha256 list (also in the job summary) and the list of tarballs this run built (`built-sha256.txt`) |
| `sign` | contents read | free-tier signing key (signing step only) | bound to the `release-signing` Environment; downloads what the Release serves, refuses it unless it equals the list `publish` recorded, signs the source tarballs and, under their own domain, the platform tarballs of every plugin it signed (only tarballs this run built); runs the tag's own commit of `sign.sh` |
| `release-data` | contents write, pull-requests write | none | writes `releases.json` from the published bytes and the signatures, opens the release-data PR |

Checksums and signatures always describe the bytes on the Release, never a rebuild. Gzip output differs between machines, so an
asset that is already on the Release stays the artifact of record and everything downstream follows it.

### Signature format (contract `plugins.release-signature` v1, hq PLUG D7)
### Signature format (contract `plugins.release-signature` v1.1, hq PLUG D7)

- Message: `nself-plugin-v1\n<slug>\n<version>\n<sha256 hex of the source tarball>\n`, signed with raw Ed25519
(`openssl pkeyutl -sign -rawin`).
- `release_signature` in `releases.json` and the projections: `{"key_id": "plugins-<16 hex>", "alg": "ed25519", "sig": "<base64>"}`.
`sig` is the 64-byte signature in standard padded base64 (the text `sdk/go/signing` `EncodeSig` produces); `verify-signatures.sh` accepts only that canonical text. `key_id` is derived,
never chosen: `plugins-` plus the first 16 hex of sha256 over the raw 32-byte public key. `sign.sh` refuses a key id that does not match its key.
- Only the source tarball `<slug>-<version>.tar.gz` is signed. The per-platform binary tarballs are covered by `platform_checksums` (in the unsigned
`releases.json`) and by the provenance attestation; verify them with `gh attestation verify <file> --repo nself-org/plugins`. Only bytes this run built
- The source tarball `<slug>-<version>.tar.gz` is signed under the message above. Since v1.1 each platform (binary) tarball
`<slug>-<version>-<platform>.tar.gz` is signed too, under its own domain: `nself-plugin-bin-v1\n<slug>\n<version>\n<platform>\n<sha256 hex of that
platform tarball>\n`, the same raw Ed25519 and the same tier key. `<platform>` is one of `darwin-amd64`, `darwin-arm64`, `linux-amd64`, `linux-arm64`,
`windows-amd64`. The first lines differ, so a source signature never verifies as a platform signature and the reverse. `releases.json` rows carry
`platform_signatures` `{"<platform>": {"key_id", "alg", "sig"}}` next to `platform_checksums`. A platform signature covers slug, version, platform and the tarball
bytes; it does not cover the registry's `binaryName`, `pluginType` or `repository` fields, so a hostile registry can choose which signed tarball of a slug and
version installs, never unsigned bytes. The provenance attestation (`gh attestation verify <file> --repo nself-org/plugins`) stays as a second check. Only bytes this run built
and the Release still serves unchanged are attested: a tarball uploaded earlier (a draft upload, an older run) is published and checksummed but carries no
attestation, and the `publish` job summary says which.
`verify-signatures.sh` counts source tarballs (checksum + signature) and binary tarballs (checksum only) separately.
`verify-signatures.sh` counts source tarballs and binary tarballs separately, each checked for checksum and signature; on a signed row a binary tarball with a
checksum and no platform signature fails (`unsigned platform`).
- The `sign` job signs a platform tarball only if its `<sha256> <name>` line is in `built-sha256.txt`, the list `publish` wrote of the tarballs this run built
(`scripts/release/platform-built-check.sh`). The publish job never uses `--clobber`, so a tarball already on the Release wins over this run's build; without
this check anyone with `contents: write` could upload a binary while the reviewer decides and receive the official platform signature. A refusal fails the job
with nothing more signed: delete that platform tarball AND its `.sha256` from the Release (the first run uploaded a `.sha256` from the foreign bytes, and `publish`
never overwrites one) and re-run, so this run's build is the one published. Two consequences to plan for: a draft-first upload of platform tarballs (the old flow) now fails `sign`,
so for a plugin with binaries let the workflow publish the platform tarballs (or delete the draft's platform assets before tagging); and a full re-run whose rebuild is not
byte-identical to the published platform tarballs (changed runner gzip or Go toolchain) fails `sign` the same way.
- `release-data` writes a platform signature into `releases.json` only when the signed sha256 equals the sha256 of the published bytes in that run; a published
platform tarball with no such signature stops the write (`UNSIGNED`), so a release never lands half-signed. Rewriting an existing row keeps a platform signature only
while the row's version, source sha256 and that platform's sha256 are all unchanged.
- The licensed tier has no platform producer (bundles serves source through ping); a pinned licensed tier keeps refusing platform packages (debt D-1286).
- A source tarball is signed only when its plugin's manifest `version` equals the tag's version; the signed set is exactly the set of rows
`release-data` writes. Both scripts accept Ed25519 keys only and refuse anything else.
- Verification order is checksum, then signature, both before extraction.
Expand All @@ -58,9 +75,11 @@ asset that is already on the Release stays the artifact of record and everything

| Script | Use |
|---|---|
| `sign.sh SLUG VERSION TARBALL` | prints the `release_signature` JSON. Key from `NSELF_RELEASE_SIGNING_KEY_FREE` (PKCS8 PEM) and `NSELF_RELEASE_SIGNING_KEY_FREE_ID`, environment only; written to a 0600 temp file removed on exit |
| `verify-signatures.sh --pub FILE DIR` | checks every `*.tar.gz` in DIR against `DIR/releases.json` and exactly the public key FILE (PEM SPKI). Failures on stderr: `checksum mismatch`, `missing release data`, `missing tarball`, `unsigned`, `signature mismatch`. No key discovery |
| `rehearse.sh --tag T --no-upload --out DIR` | builds all source tarballs and signs them with a throwaway key; writes `DIR/releases.json` and `DIR/rehearsal-test.pub`. Two runs of one tag give byte-identical tarballs |
| `sign.sh SLUG VERSION TARBALL [PLATFORM]` | prints the `release_signature` JSON (with `PLATFORM`, the platform-domain signature for that platform tarball; a fifth argument or an unknown platform is exit 2). Key from `NSELF_RELEASE_SIGNING_KEY_FREE` (PKCS8 PEM) and `NSELF_RELEASE_SIGNING_KEY_FREE_ID`, environment only; written to a 0600 temp file removed on exit |
| `verify-signatures.sh --pub FILE DIR` | checks every `*.tar.gz` in DIR against `DIR/releases.json` and exactly the public key FILE (PEM SPKI). Failures on stderr: `checksum mismatch`, `missing release data`, `missing tarball`, `unsigned`, `signature mismatch`, `unsigned platform`, `platform signature mismatch`, `orphan platform signature`. No key discovery |
| `platform-built-check.sh BUILT_LIST DIR` | exit 0 only if every platform tarball in DIR has its exact `<sha256> <name>` line in BUILT_LIST (the sign job's gate); `not built by this run: <file>` otherwise |
| `platform-signatures-test.sh` | offline gate: platform signatures accepted only for their slug, version, platform and bytes; built-check cases; the committed fixture `testdata/platform-v1/` (throwaway key, made by `sign.sh`) for the cli verifier. `--regen-testdata` rebuilds the fixture |
| `rehearse.sh --tag T --no-upload --out DIR` | builds all source tarballs and signs them with a throwaway key, plus fixture binary tarballs (stub payload, all five platforms) for the first slug; writes `DIR/releases.json` and `DIR/rehearsal-test.pub`. Two runs of one tag give byte-identical tarballs |
| `keys/` | committed release public keys (`plugins-release-free-<id>.pub`). Empty until P7-PLUG-69 commits the production key, which must happen before the first real tag run |

OpenSSL 3 is required (`OPENSSL=/path/to/openssl` to pick one); macOS system LibreSSL cannot sign or verify Ed25519 with `-rawin`.
Expand Down
85 changes: 72 additions & 13 deletions .github/workflows/release-tarballs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@ name: Release Plugin Tarballs (Free)
# build deterministic source + per-platform tarballs (scripts/deterministic-tar.sh); no secrets, read-only token.
# publish uploads the missing tarballs to the GitHub Release (never --clobber), writes the .sha256 files from the PUBLISHED
# bytes, mirrors those bytes to Cloudflare R2 and attests build provenance for the tarballs this run built and the
# Release serves unchanged; records the published sha256 list the signer is held to. Holds the R2 secrets, never the
# signing key.
# Release serves unchanged; records the published sha256 list the signer is held to and the sha256 list of the
# tarballs THIS run built (built-sha256.txt). Holds the R2 secrets, never the signing key.
# sign bound to the protected Environment `release-signing` (required reviewer): the only job that can read the free-tier
# signing key (NSELF_RELEASE_SIGNING_KEY_FREE + _ID). Downloads what is on the Release, refuses it unless it is
# byte-for-byte what publish recorded, and signs the source tarballs per hq PLUG D7 (scripts/release/sign.sh). The
# licensed key is never here. Runs the tag's own commit of sign.sh, the code the reviewer approved.
# release-data re-downloads the published bytes, writes sha256 + release_signature into releases.json
# byte-for-byte what publish recorded, and signs the source tarballs per hq PLUG D7 (scripts/release/sign.sh) and, under
# their own domain (contract plugins.release-signature v1.1), the platform tarballs of every signed plugin, but only
# tarballs this run built (scripts/release/platform-built-check.sh). The licensed key is never here. Runs the tag's own commit of sign.sh, the code the reviewer approved.
# release-data re-downloads the published bytes, writes sha256 + release_signature + platform_signatures into releases.json
# (scripts/verify-published-checksums.sh --write --signatures) and opens the release-data PR with the workflow's own
# GITHUB_TOKEN. No PAT, no cross-repo trigger. The projections (registry.json, catalog.json, counts.json) need the
# private bundles repo, which this token cannot read: the PR body tells the maintainer to regenerate them.
# Checksums and signatures always describe the bytes on the Release, never a rebuild: the Release asset is the artifact of record.
# Idempotent: re-running skips assets already present on the release. See .github/wiki/Releasing.md.
# Idempotent: re-running skips assets already present on the release. Limit (contract v1.1): the `sign` job signs a platform tarball
# only if THIS run built the exact bytes, so a full re-run whose rebuild is not byte-identical to the published platform tarballs
# (a changed runner gzip/zlib or Go toolchain) fails `sign`; a draft-first upload of platform tarballs fails it too. See Releasing.md.
# See .github/wiki/Releasing.md.

on:
push:
Expand Down Expand Up @@ -394,13 +398,21 @@ jobs:

# What the reviewer of the `sign` job approves: the sha256 of every tarball the Release serves right now. `sign` refuses any
# tarball that is not in this list with this hash, so an asset swapped while the approval waits is never signed.
#
# built-sha256.txt is the shorter list the platform signer is held to: the tarballs this run built that the Release serves
# unchanged (attest/). A platform tarball is signed only if its "<sha256> <name>" line is in it, so a binary uploaded to the
# Release by anyone else during the approval wait is published but never signed.
- name: Record the published checksums
run: |
(cd published && sha256sum -- *.tar.gz) > published-sha256.txt
: > built-sha256.txt
if compgen -G 'attest/*.tar.gz' >/dev/null; then
(cd attest && sha256sum -- *.tar.gz) > built-sha256.txt
fi
{
echo "### Published tarballs for ${TAG}"
echo
echo "$(wc -l < published-sha256.txt | tr -d ' ') tarball(s) on the Release; $(wc -l < not-attested.txt | tr -d ' ') differ from this run's rebuild (published, not attested)."
echo "$(wc -l < published-sha256.txt | tr -d ' ') tarball(s) on the Release; $(wc -l < not-attested.txt | tr -d ' ') differ from this run's rebuild (published, not attested); $(wc -l < built-sha256.txt | tr -d ' ') built by this run (source and platform tarballs); only those platform tarballs are eligible for platform signing."
echo
echo '```'
cat published-sha256.txt
Expand All @@ -418,7 +430,9 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: published-sha256
path: published-sha256.txt
path: |
published-sha256.txt
built-sha256.txt
retention-days: 30
if-no-files-found: error

Expand Down Expand Up @@ -549,10 +563,11 @@ jobs:
(cd published && sha256sum -- *.tar.gz) | sort > actual-sha256.txt
sort expected/published-sha256.txt | diff - actual-sha256.txt || { echo "ERROR: the Release no longer serves the bytes the publish job recorded; nothing is signed"; exit 1; }

# Source tarballs only (<slug>-<version>.tar.gz): the signed message names slug, version and the source tarball's sha256.
# Binary tarballs (<slug>-<version>-<platform>.tar.gz) are covered by platform_checksums and, when this run built them, the
# provenance attestation made in the publish job.
- name: Sign the published source tarballs (hq PLUG D7)
# Source tarballs (<slug>-<version>.tar.gz): the signed message names slug, version and the source tarball's sha256.
# Platform tarballs (<slug>-<version>-<platform>.tar.gz) of every plugin signed here are signed under their own domain
# (contract v1.1): the message names slug, version, platform and that tarball's sha256. They are signed only if
# platform-built-check.sh confirms this run built the exact bytes; otherwise the job fails and signs nothing more.
- name: Sign the published source and platform tarballs (hq PLUG D7, contract v1.1)
env:
NSELF_RELEASE_SIGNING_KEY_FREE: ${{ secrets.NSELF_RELEASE_SIGNING_KEY_FREE }}
NSELF_RELEASE_SIGNING_KEY_FREE_ID: ${{ secrets.NSELF_RELEASE_SIGNING_KEY_FREE_ID }}
Expand Down Expand Up @@ -586,12 +601,56 @@ jobs:
rm -f signatures.ndjson
echo "Signed ${count} source tarball(s)"

# Platform tarballs of the signed plugins only (a plugin left unsigned above gets none). Every one must be a tarball this
# run built: the Release never takes a clobbering upload, so an asset put there by anyone else during the approval wait
# is already published, and signing it would hand it the official platform signature.
platforms="darwin-amd64 darwin-arm64 linux-amd64 linux-arm64 windows-amd64"
bin_stage="$(mktemp -d)"
for slug in $(jq -r 'keys[]' signatures.json); do
for platform in $platforms; do
f="published/${slug}-${VERSION}-${platform}.tar.gz"
[ -e "$f" ] || continue
ln -s "${PWD}/${f}" "$bin_stage/"
done
done
if [ -z "$(ls -A "$bin_stage")" ]; then
echo "No platform tarball for the signed plugins; nothing to sign beyond the source tarballs"
elif ! bash scripts/release/platform-built-check.sh expected/built-sha256.txt "$bin_stage"; then
echo "ERROR: a platform tarball on the Release was not built by this run; nothing is signed. Delete that platform tarball AND its .sha256 from the Release, then re-run the release (Releasing.md)."
exit 1
fi
: > platform-signatures.ndjson
pcount=0
for slug in $(jq -r 'keys[]' signatures.json); do
for platform in $platforms; do
f="published/${slug}-${VERSION}-${platform}.tar.gz"
[ -e "$f" ] || continue
psig="$(bash scripts/release/sign.sh "$slug" "$VERSION" "$f" "$platform")"
psha="$(sha256sum "$f" | cut -d' ' -f1)"
jq -nc --arg s "$slug" --arg p "$platform" --arg sha "$psha" --argjson sig "$psig" \
'{($s): {platform_signatures: {($p): ($sig + {sha256: $sha})}}}' >> platform-signatures.ndjson
pcount=$((pcount + 1))
done
done
jq -sS 'reduce .[1:][] as $r (.[0]; . * $r)' signatures.json platform-signatures.ndjson > signatures.merged.json
mv signatures.merged.json signatures.json
rm -f platform-signatures.ndjson
echo "Signed ${pcount} platform tarball(s)"

# Once a release public key is committed (P7-PLUG-69 commits the first), only a committed key may have signed.
# The stage mirrors releases.json: platform_checksums from the covered sha256, platform_signatures without it.
stage="$(mktemp -d)"
jq --arg v "$VERSION" 'map_values(. + {version: $v}) | {releases: .}' signatures.json > "$stage/releases.json"
jq --arg v "$VERSION" 'map_values(. + {version: $v}
+ (if has("platform_signatures") then {platform_checksums: (.platform_signatures | map_values(.sha256)),
platform_signatures: (.platform_signatures | map_values(del(.sha256)))} else {} end))
| {releases: .}' signatures.json > "$stage/releases.json"
for slug in $(jq -r 'keys[]' signatures.json); do
ln -s "${PWD}/published/${slug}-${VERSION}.tar.gz" "$stage/"
done
for f in "$bin_stage"/*.tar.gz; do
[ -e "$f" ] || continue
ln -s "$(readlink "$f")" "$stage/"
done
pinned=0
ok=0
for k in scripts/release/keys/plugins-release-free-*.pub; do
Expand Down
2 changes: 1 addition & 1 deletion schemas/SOURCE
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Written by scripts/conformance/sync-schema.sh. Do not edit.
repo=https://github.com/nself-org/cli
sha=6fe7bc673b1dbe95db3e92464a9f395772e7ee3a
sha=5b214db6008d4991144b639bfc7a8beda6b83d8c
path=schemas/plugin-manifest.v2.schema.json
sha256=d530b7e60260f804fb6f6c823a236d43bbb519d432afc1885074832f40435e0c
Loading
Loading