CI: never replace files already on the release
What changed, and why it matters
This commit fixes a release-pipeline bug where re-running the automated release job could accidentally wipe out human maintainers' cryptographic signatures from the release's checksum file. Before the fix, the CI tool replaced any existing file with the same name, so if release captains had already added their GPG signatures to SHA256SUMS-<version>.asc, a re-run would overwrite it with a version signed only by the CI bot. Users who later checked the signatures would see only one signature and could not verify that multiple maintainers had approved the release. The fix introduces a new script that only adds missing files, refuses to change files that differ, and appends the CI signature to existing signature files instead of replacing them.
Review and merge the commit, then verify the new script in a staging release workflow. Ensure the GH_TOKEN and gpg signing key are available in the release job. Consider adding a CI test that simulates a re-run against a draft release to confirm .asc files are appended, not overwritten, and that mismatched non-signature files cause the job to abort.
Security signals we found
Loss of maintainer signatures on release artifacts
CI job overwriting manually-added GPG signatures
Supply-chain / release-integrity risk from incomplete signature set
New script enforces byte-identical non-signature assets and signature merging
Normalization of PGP armor blocks to prevent skipped signatures
Evidence from the diff
The change replaces the softprops/action-gh-release@v3 step in .github/workflows/release-publish.yml with a shell script, tools/publish-release-assets.sh. The old action uploaded release/, release-arm64/, and release-armv7/* and overwrote any existing asset with the same name. The new script: (1) creates the draft release with gh if it does not exist; (2) for non-.asc files, verifies byte identity with the published copy before uploading missing files and aborts on mismatch; (3) for .asc files, verifies the local signature, checks whether the published .asc already contains the same signer fingerprint, and if not, normalizes the ASCII armor, concatenates the published and local signatures, verifies the merged file contains the expected number of signatures and that the local signature validates, then uploads with gh release upload –clobber. The script also normalizes PGP armor blocks to avoid gpg silently skipping concatenated signatures when no blank line follows the BEGIN line.
Changed components
.github/workflows/release-publish.ymltools/publish-release-assets.shdoc/contribute-to-core-lightning/release-checklist.mdInspect captured patch +145 / −13
### .github/workflows/release-publish.yml
@@ -94,16 +94,24 @@ jobs:
RELEASE_TITLE=$(echo $CHANGELOG_TITLE | cut -d'"' -f2)
echo "release_title=$RELEASE_TITLE" >> "$GITHUB_OUTPUT"
+ # Never replace what is already on the release: release captains add
+ # their signatures to the .asc files by hand, and a re-run must keep
+ # them. tools/publish-release-assets.sh uploads only missing files,
+ # refuses any that differ from the published copy, and appends our
+ # signature to an existing .asc instead of overwriting it.
- name: Prepare release draft
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.create_release) }}
- uses: softprops/action-gh-release@v3
- with:
- name: "${{ inputs.version }} ${{ steps.release_data.outputs.release_title }}"
- tag_name: ${{ inputs.version }}
- draft: true
- prerelease: contains(inputs.version, "-rc")
- files: |
- release/*
- release-arm64/*
- release-armv7/*
- fail_on_unmatched_files: true
+ env:
+ GH_TOKEN: ${{ github.token }}
+ INPUT_VERSION: ${{ inputs.version }}
+ RELEASE_TITLE: ${{ steps.release_data.outputs.release_title }}
+ run: |
+ if ! gh release view "$INPUT_VERSION" >/dev/null 2>&1; then
+ PRERELEASE=""
+ case "$INPUT_VERSION" in *-rc*) PRERELEASE=--prerelease;; esac
+ # --target only matters for an untagged test run: gh then
+ # creates the tag, as the previous upload action did.
+ gh release create "$INPUT_VERSION" --draft --target "$GITHUB_SHA" $PRERELEASE \
+ --title "$INPUT_VERSION $RELEASE_TITLE" --notes ""
+ fi
+ tools/publish-release-assets.sh "$INPUT_VERSION" release/* release-arm64/* release-armv7/*
### doc/contribute-to-core-lightning/release-checklist.md
@@ -33,7 +33,7 @@ Here's a checklist for the release process.
3. Confirm that the tag will show up for builds with `git describe`. We don't push it to GitHub yet, just in case the following steps fail, and more fixes are required!
4. Run `contrib/cl-repro.sh` to generate the required `cl-repro-<codename>` builder images for the reproducible build environment.
5. Execute `tools/build-release.sh bin-Fedora bin-Ubuntu sign` to locally reproduce the release, generating a matching `SHA256SUMS-v<VERSION>` file and signing it with your GPG key.
-6. Push the tag to trigger the "Release 🚀" CI action, which drafts a new `v<VERSION>rc1` pre-release on GitHub and uploads reproducible builds alongside the `SHA256SUMS-v<VERSION>` file and its signature from the `cln@blockstream.com` key.
+6. Push the tag to trigger the "Release 🚀" CI action, which drafts a new `v<VERSION>rc1` pre-release on GitHub and uploads reproducible builds alongside the `SHA256SUMS-v<VERSION>` file and its signature from the `cln@blockstream.com` key. The arm64 and armv7 tarballs each come with their own manifest, `SHA256SUMS-v<VERSION>-arm64` and `SHA256SUMS-v<VERSION>-armv7`, and its `.asc`. The CI never replaces a file already on the release: re-running it only adds missing files, and appends its signature to an `.asc` that already has yours.
7. Verify your local `SHA256SUMS-v<VERSION>` file matches the one in the draft release, then append your local signatures to the release's `SHA256SUMS-v<VERSION>.asc` file to attest to the build's integrity.
8. Announce rc1 release on core-lightning's release-chat channel on Discord & Telegram.
9. Use `devtools/credit --markdown v<PREVIOUS-VERSION>` to generate a single contributor list for the release notes. Use `devtools/credit --verbose v<PREVIOUS-VERSION>` for namer selection and detailed annotations.
@@ -119,7 +119,7 @@ Here's a checklist for the release process.
5. Create a new commit that includes the updates from `update-versions` and `CHANGELOG.md`.
6. Tag the release with `git pull && git tag -s v<VERSION>.<POINT_VERSION>`. You will be prompted to enter a tag message, ensure this is filled out.
7. Confirm that the tag is properly set up for builds by running `git describe`.
-8. Trigger the pre-release by pushing the version tag with `git push origin v<VERSION>.<POINT_VERSION>`; the CI will handle drafting the release and uploading the initial signed checksums.
+8. Trigger the pre-release by pushing the version tag with `git push origin v<VERSION>.<POINT_VERSION>`; the CI will handle drafting the release and uploading the initial signed checksums, for amd64 (`SHA256SUMS-v<VERSION>`), arm64 (`SHA256SUMS-v<VERSION>-arm64`) and armv7 (`SHA256SUMS-v<VERSION>-armv7`).
9. Generate the required builder images by running `contrib/cl-repro.sh`.
10. Sign the release locally by running `tools/build-release.sh bin-Fedora bin-Ubuntu sign` which will sign the release contents and create `SHA256SUMS-v<VERSION>` and `SHA256SUMS-v<VERSION>.asc` in the release folder.
11. Validate that your local checksums `SHA256SUMS-v<VERSION>` match the Draft release's, then add your signatures to the draft release's signature `SHA256SUMS-v<VERSION>.asc` file.
### tools/publish-release-assets.sh
@@ -0,0 +1,124 @@
+#! /bin/sh
+# Upload release files to an existing GitHub release without replacing
+# anything already published there.
+#
+# - A file the release does not have yet is uploaded.
+# - A file the release already has must be byte-identical, or we stop: a
+# different tarball or SHA256SUMS means the builds disagree, and nothing is
+# touched.
+# - A detached signature (.asc) the release already has is merged instead:
+# our signature is appended to the published one, so the signatures that
+# release captains added by hand are kept. If the published file already
+# carries our signature it is left alone, so re-running is harmless.
+#
+# Usage: tools/publish-release-assets.sh <tag> <file>...
+# Needs gh (with GH_TOKEN) and gpg with the signing key that made our .asc.
+set -e
+
+TAG=${1:-}
+if [ -z "$TAG" ] || [ $# -lt 2 ]; then
+ echo "Usage: $0 <tag> <file>..." >&2
+ exit 1
+fi
+shift
+
+WORK=$(mktemp -d)
+trap 'rm -rf "$WORK"' EXIT
+
+gh release view "$TAG" --json assets --jq '.assets[].name' > "$WORK/published"
+
+is_published()
+{
+ grep -qxF "$1" "$WORK/published"
+}
+
+fetch()
+{
+ rm -f "$WORK/remote"
+ gh release download "$TAG" --pattern "$1" --output "$WORK/remote"
+}
+
+# The fingerprint of every good signature in $1 over $2, one per line.
+good_sigs()
+{
+ gpg --status-fd 1 --verify "$1" "$2" 2>/dev/null | awk '$2 == "VALIDSIG" {print $3}' || true
+}
+
+# Count signature packets, whether or not we have the signer's key.
+count_sigs()
+{
+ gpg --list-packets "$1" 2>/dev/null | grep -c '^:signature packet' || true
+}
+
+# Concatenating armored signatures can leave a block gpg only half-reads
+# (no blank line after the BEGIN line), and it then silently skips it.
+normalise()
+{
+ awk '/^-----BEGIN PGP SIGNATURE-----$/{print; getline l; if (l != "") print ""; print l; next} {print}' "$1"
+}
+
+# Pass 1: everything except signatures. Stop before any upload if a
+# published file differs from ours.
+for f; do
+ case "$f" in *.asc) continue;; esac
+ name=$(basename "$f")
+ if is_published "$name"; then
+ fetch "$name"
+ if ! cmp -s "$f" "$WORK/remote"; then
+ echo "$name: published copy differs from ours, refusing to continue" >&2
+ exit 1
+ fi
+ fi
+done
+
+for f; do
+ case "$f" in *.asc) continue;; esac
+ name=$(basename "$f")
+ if is_published "$name"; then
+ echo "$name: already published, identical"
+ else
+ echo "$name: uploading"
+ gh release upload "$TAG" "$f"
+ fi
+done
+
+# Pass 2: signatures. The file each one signs is now the published one.
+for f; do
+ case "$f" in *.asc) ;; *) continue;; esac
+ name=$(basename "$f")
+ signed="${f%.asc}"
+ [ -f "$signed" ] || { echo "$name: no $signed next to it" >&2; exit 1; }
+
+ ours=$(good_sigs "$f" "$signed")
+ [ -n "$ours" ] || { echo "$name: our own signature does not verify" >&2; exit 1; }
+
+ if ! is_published "$name"; then
+ echo "$name: uploading"
+ gh release upload "$TAG" "$f"
+ continue
+ fi
+
+ fetch "$name"
+ if good_sigs "$WORK/remote" "$signed" | grep -qxF "$ours"; then
+ echo "$name: already carries our signature, left alone"
+ continue
+ fi
+
+ # Count after normalising: that also repairs a block the published file
+ # already had that gpg could not read.
+ normalise "$WORK/remote" > "$WORK/remote.norm"
+ before=$(count_sigs "$WORK/remote.norm")
+ mkdir -p "$WORK/merged"
+ { cat "$WORK/remote.norm"; normalise "$f"; } > "$WORK/merged/$name"
+ after=$(count_sigs "$WORK/merged/$name")
+ if [ "$after" -ne $((before + $(count_sigs "$f"))) ]; then
+ echo "$name: merged file has $after signatures, expected $before plus ours; not uploading" >&2
+ exit 1
+ fi
+ good_sigs "$WORK/merged/$name" "$signed" | grep -qxF "$ours" || {
+ echo "$name: our signature does not verify in the merged file; not uploading" >&2
+ exit 1
+ }
+ echo "$name: appending our signature to the $before already published"
+ gh release upload --clobber "$TAG" "$WORK/merged/$name"
+doneWhy this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.