Skip to content

Commit ef100f4

Browse files
committed
Wire COPR publish into the release pipeline (Phase 7)
Adds COPR (Fedora + EPEL) to the cargo-dist user_publish_jobs pipeline. Same shape as the existing crates.io / Docker / .deb publish jobs. Why the spec-file approach: copr-cli's only build entrypoint accepts SRPMs or spec files — it doesn't import prebuilt binary RPMs. So the binary-everywhere property we have for crates.io, GHCR, .deb, and AUR needed a thin spec whose %prep downloads the SLSA-attested upstream tarball cargo-dist already published, verifies it against the .sha256 sidecar, and then %install just lays the binary into the buildroot. COPR's mock chroot does this on each Fedora + EPEL chroot in ~30 seconds — no Rust toolchain involved, single trust chain. Why four secrets instead of one combined config blob: copr-cli only reads credentials from ~/.config/copr (no env-var fallback per copr/v3/helpers.py's config_from_file). The workflow has to assemble that file at runtime regardless. Splitting the four fields (login, username, token, copr_url) into separate repo secrets makes each one safe to paste as a single line (no multiline blob to get malformed) and lets the token be rotated without touching the other three. Changes: * packaging/qn-bin.spec — the thin spec. ExclusiveArch: x86_64 aarch64. Per-arch Source URL via ifarch, sha256 verification in %prep before %setup. * .github/workflows/publish-copr.yml — reusable workflow invoked by cargo-dist. Builds the SRPM from the spec (with QN_VERSION passed in from the dist manifest), assembles ~/.config/copr from the four COPR_* secrets, then dispatches via `copr-cli build --enable-net=on quicknode/qn <srpm>`. --enable-net=on is required because %prep curls the tarball; COPR's default mock has network disabled. * dist-workspace.toml — adds ./publish-copr to publish-jobs and grants `contents: read` via github-custom-job-permissions. * RELEASING.md — documents the COPR channel under CI publish channels and the four-secret provisioning flow. * release.yml — regenerated to include custom-publish-copr. The job is gated by cargo-dist on `!is_prerelease`, same as the others. Until all four COPR_* secrets are set on the repo, the job will fail loudly at the "Configure copr-cli" step with a clear error listing which fields are missing — the rest of the release continues to succeed. Earlier in this branch I went down a wrong path: a `release-build-copr-rpms` Justfile recipe + [package.metadata.generate-rpm] block that produced binary RPMs locally with cargo-generate-rpm. `copr-cli build` doesn't accept binary RPMs, so that path was a dead-end. Reset and rewrote before opening the PR for review.
1 parent 8869c91 commit ef100f4

5 files changed

Lines changed: 230 additions & 4 deletions

File tree

.github/workflows/publish-copr.yml

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
name: Publish RPMs to COPR
2+
3+
# Reusable workflow invoked by cargo-dist's release pipeline as a
4+
# user_publish_job (see dist-workspace.toml `publish-jobs`).
5+
#
6+
# Builds an SRPM from packaging/qn-bin.spec — a thin .spec whose %prep
7+
# downloads the SLSA-attested prebuilt binary cargo-dist publishes for
8+
# this release and verifies it against the .sha256 sidecar. No Rust
9+
# toolchain involved on COPR's side; the binary inside the resulting
10+
# RPM is bit-identical to the one in crates.io, Homebrew, GHCR, .deb,
11+
# and AUR.
12+
#
13+
# `copr-cli build --enable-net=on` is required because the spec's
14+
# %prep stage fetches the tarball over HTTPS from the GitHub Release.
15+
on:
16+
workflow_call:
17+
inputs:
18+
plan:
19+
description: dist-manifest JSON for this announcement
20+
required: true
21+
type: string
22+
23+
# Reusable-workflow permissions must not exceed what the caller grants
24+
# in dist-workspace.toml's github-custom-job-permissions block. We only
25+
# need to read this repo to access the spec file.
26+
permissions:
27+
contents: read
28+
29+
jobs:
30+
publish:
31+
runs-on: ubuntu-24.04
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- name: Extract release tag from plan
36+
id: meta
37+
env:
38+
PLAN: ${{ inputs.plan }}
39+
run: |
40+
version=$(echo "$PLAN" | jq -r '.releases[0].app_version')
41+
echo "version=$version" >> "$GITHUB_OUTPUT"
42+
43+
- name: Install rpmbuild + copr-cli
44+
run: |
45+
sudo apt-get update
46+
sudo apt-get install -y rpm python3-pip python3-venv
47+
python3 -m venv "$HOME/copr-venv"
48+
"$HOME/copr-venv/bin/pip" install --quiet copr-cli rich
49+
echo "$HOME/copr-venv/bin" >> "$GITHUB_PATH"
50+
51+
- name: Build SRPM
52+
env:
53+
QN_VERSION: ${{ steps.meta.outputs.version }}
54+
run: |
55+
# Set up the rpmbuild tree.
56+
mkdir -p "$HOME/rpmbuild"/{SOURCES,SPECS,SRPMS}
57+
cp packaging/qn-bin.spec "$HOME/rpmbuild/SPECS/qn-bin.spec"
58+
59+
# SRPM only has the spec — the sources are fetched by mock at
60+
# %prep time (Source0/Source1 are URLs, not local files). We
61+
# pass --nodeps so rpmbuild doesn't try to satisfy
62+
# BuildRequires on the runner; COPR's mock handles that.
63+
rpmbuild -bs "$HOME/rpmbuild/SPECS/qn-bin.spec" \
64+
--define "_topdir $HOME/rpmbuild" \
65+
--define "qn_version $QN_VERSION" \
66+
--nodeps
67+
ls -la "$HOME/rpmbuild/SRPMS/"
68+
69+
- name: Configure copr-cli
70+
env:
71+
# copr-cli doesn't read env vars — it requires a config file at
72+
# ~/.config/copr in INI format. The four fields are provisioned
73+
# as separate repo secrets so each can be pasted as a single
74+
# line and rotated independently. The file is assembled here at
75+
# runtime. Verbatim values come from
76+
# https://copr.fedorainfracloud.org/api/.
77+
COPR_LOGIN: ${{ secrets.COPR_LOGIN }}
78+
COPR_USERNAME: ${{ secrets.COPR_USERNAME }}
79+
COPR_TOKEN: ${{ secrets.COPR_TOKEN }}
80+
COPR_URL: ${{ secrets.COPR_URL }}
81+
run: |
82+
missing=()
83+
[[ -z "$COPR_LOGIN" ]] && missing+=(COPR_LOGIN)
84+
[[ -z "$COPR_USERNAME" ]] && missing+=(COPR_USERNAME)
85+
[[ -z "$COPR_TOKEN" ]] && missing+=(COPR_TOKEN)
86+
[[ -z "$COPR_URL" ]] && missing+=(COPR_URL)
87+
if (( ${#missing[@]} > 0 )); then
88+
echo "Error: COPR secrets not set on this repo:" >&2
89+
printf ' %s\n' "${missing[@]}" >&2
90+
echo "Provision per RELEASING.md's COPR one-time-setup section." >&2
91+
exit 1
92+
fi
93+
mkdir -p ~/.config
94+
# Use printf rather than heredoc to avoid YAML-indentation
95+
# leaking into the file (configparser treats indented lines
96+
# as continuations and rejects them).
97+
printf '[copr-cli]\nlogin = %s\nusername = %s\ntoken = %s\ncopr_url = %s\n' \
98+
"$COPR_LOGIN" "$COPR_USERNAME" "$COPR_TOKEN" "$COPR_URL" \
99+
> ~/.config/copr
100+
chmod 600 ~/.config/copr
101+
102+
- name: copr-cli build
103+
run: |
104+
srpm=$(ls "$HOME/rpmbuild/SRPMS/"qn-${{ steps.meta.outputs.version }}-1*.src.rpm)
105+
if [[ ! -f "$srpm" ]]; then
106+
echo "Error: SRPM not found at expected path under SRPMS/" >&2
107+
exit 1
108+
fi
109+
echo "Uploading $srpm to quicknode/qn..."
110+
# --enable-net=on so COPR's mock chroot can curl the prebuilt
111+
# tarball + sha256 sidecar from the GitHub Release in %prep.
112+
copr-cli build quicknode/qn "$srpm" --enable-net=on

.github/workflows/release.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,17 +327,31 @@ jobs:
327327
permissions:
328328
"contents": "write"
329329

330+
custom-publish-copr:
331+
needs:
332+
- plan
333+
- host
334+
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
335+
uses: ./.github/workflows/publish-copr.yml
336+
with:
337+
plan: ${{ needs.plan.outputs.val }}
338+
secrets: inherit
339+
# publish jobs get escalated permissions
340+
permissions:
341+
"contents": "read"
342+
330343
announce:
331344
needs:
332345
- plan
333346
- host
334347
- custom-publish-crates
335348
- custom-publish-docker
336349
- custom-publish-deb
350+
- custom-publish-copr
337351
# use "always() && ..." to allow us to wait for all publish jobs while
338352
# still allowing individual publish jobs to skip themselves (for prereleases).
339353
# "host" however must run to completion, no skipping allowed!
340-
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') && (needs.custom-publish-docker.result == 'skipped' || needs.custom-publish-docker.result == 'success') && (needs.custom-publish-deb.result == 'skipped' || needs.custom-publish-deb.result == 'success') }}
354+
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') && (needs.custom-publish-docker.result == 'skipped' || needs.custom-publish-docker.result == 'success') && (needs.custom-publish-deb.result == 'skipped' || needs.custom-publish-deb.result == 'success') && (needs.custom-publish-copr.result == 'skipped' || needs.custom-publish-copr.result == 'success') }}
341355
runs-on: "ubuntu-22.04"
342356
env:
343357
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

RELEASING.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ Three named recipes in the `Justfile`:
1616
- `custom-publish-crates` → publishes `quicknode-cli` to crates.io
1717
- `custom-publish-docker` → builds multi-arch image, pushes to `ghcr.io/quicknode/qn`
1818
- `custom-publish-deb` → packages `.deb` per arch, uploads to the GitHub Release as assets
19+
- `custom-publish-copr` → builds an SRPM from `packaging/qn-bin.spec` (whose `%prep` downloads the SLSA-attested prebuilt binary), dispatches via `copr-cli build` to the `quicknode/qn` COPR project
1920

20-
3. **Maintainer manual steps after CI succeeds.** Three channels still need a person to drive the publish because CI doesn't yet have the credentials it needs.
21+
3. **Maintainer manual steps after CI succeeds.** Two channels still need a person to drive the publish because CI doesn't yet have the credentials it needs.
2122

2223
## Manual steps after each release
2324

@@ -98,6 +99,26 @@ The first push registers the package on the AUR. Subsequent pushes just update i
9899

99100
After publishing: confirm via `https://aur.archlinux.org/packages/qn-bin` (the RPC at `/rpc/v5/info` can lag the package page by a few minutes — trust the web page, not the RPC, for fresh registrations).
100101

102+
### COPR (`quicknode/qn`)
103+
104+
Maintainer needs a Fedora account at <https://accounts.fedoraproject.org> and a COPR project `quicknode/qn` created at <https://copr.fedorainfracloud.org>. Project settings to set on creation:
105+
106+
- **Chroots**: current Fedora releases + EPEL 9 (covers RHEL 9, Rocky 9, Alma 9). Skip EPEL 8 unless requested — its glibc is too old for our gnu binaries.
107+
- **Build settings**: enable internet access for builds (the `%prep` step in `packaging/qn-bin.spec` curls the prebuilt tarball from the GitHub Release; without net the build can't download it).
108+
109+
CI auths to COPR via four repo secrets — `copr-cli` itself only reads credentials from a config file at `~/.config/copr` (no env-var fallback), so we provision the four fields separately and let the workflow assemble the file at build time. Generate the values at <https://copr.fedorainfracloud.org/api/>; the page shows a `[copr-cli]` config block with these four lines. Copy each field's value into its own secret:
110+
111+
| Secret | Field from the COPR API page |
112+
|---|---|
113+
| `COPR_LOGIN` | `login = …` |
114+
| `COPR_USERNAME` | `username = …` |
115+
| `COPR_TOKEN` | `token = …` |
116+
| `COPR_URL` | `copr_url = …` (almost always `https://copr.fedorainfracloud.org`) |
117+
118+
Set each with `gh secret set COPR_LOGIN --repo quicknode/cli` etc., pasting just the value (no `login = ` prefix, no quotes). Splitting them this way also means the token can be rotated without re-pasting the other three.
119+
120+
`packaging/qn-bin.spec` lives in this repo; it's a thin spec whose `%prep` downloads the SLSA-attested prebuilt linux-gnu tarball and verifies it against the `.sha256` sidecar, so COPR isn't rebuilding `qn` from Rust source — it's just packaging the upstream binary into an RPM per chroot. Same trust chain as everywhere else qn ships.
121+
101122
## Recovery: a publish channel failed
102123

103124
If a single publish-* job in `release.yml` fails (e.g. crates.io rejected the publish because the token expired), the rest of the release is still good — the GitHub Release, attestations, and other channels remain published.

dist-workspace.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ tap = "quicknode/homebrew-tap"
2929
# Override the Homebrew formula name so `brew install quicknode/tap/qn` works
3030
# (default would derive it from the package name `quicknode-cli`).
3131
formula = "qn"
32-
publish-jobs = ["./publish-crates", "./publish-docker", "./publish-deb"]
32+
publish-jobs = ["./publish-crates", "./publish-docker", "./publish-deb", "./publish-copr"]
3333
# Emit SLSA build attestations for every binary archive. Critical for a credential-handling tool.
3434
github-attestations = true
3535

@@ -41,4 +41,4 @@ github-attestations = true
4141
# the calling job's permission grant so the called workflow can also
4242
# declare contents:read without exceeding the caller. publish-deb
4343
# needs contents: write so it can `gh release upload` the .deb files.
44-
github-custom-job-permissions = { "publish-crates" = { contents = "read" }, "publish-docker" = { contents = "read", packages = "write", "id-token" = "write" }, "publish-deb" = { contents = "write" } }
44+
github-custom-job-permissions = { "publish-crates" = { contents = "read" }, "publish-docker" = { contents = "read", packages = "write", "id-token" = "write" }, "publish-deb" = { contents = "write" }, "publish-copr" = { contents = "read" } }

packaging/qn-bin.spec

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# qn-bin: install the SLSA-attested binary cargo-dist ships, no rebuild.
2+
#
3+
# This spec is built on COPR's mock chroots. `%prep` downloads the per-arch
4+
# linux-gnu tarball from the GitHub Release and verifies it against the
5+
# .sha256 sidecar; `%install` lays the binary + docs into the buildroot.
6+
# No Rust toolchain involved on the COPR side — the binary inside the
7+
# resulting RPM is bit-identical to what ships in crates.io,
8+
# Homebrew, .deb, and the GHCR image.
9+
#
10+
# Built and uploaded by .github/workflows/publish-copr.yml on each release.
11+
# Requires --enable-net=on at build time (mock fetches the tarball).
12+
13+
%global qn_version %{getenv:QN_VERSION}
14+
15+
%if "%{qn_version}" == ""
16+
%{error: QN_VERSION must be set when building this spec (e.g. rpmbuild --define "qn_version 0.1.4")}
17+
%endif
18+
19+
Name: qn
20+
Version: %{qn_version}
21+
Release: 1%{?dist}
22+
Summary: Command-line interface for the Quicknode SDK
23+
License: MIT
24+
URL: https://github.com/quicknode/cli
25+
26+
# cargo-dist emits separate tarballs per Rust target triple. We map COPR's
27+
# arch tokens to those triples; the per-arch Source entry below picks the
28+
# right one at build time.
29+
%ifarch x86_64
30+
%global rust_target x86_64-unknown-linux-gnu
31+
%endif
32+
%ifarch aarch64
33+
%global rust_target aarch64-unknown-linux-gnu
34+
%endif
35+
36+
Source0: https://github.com/quicknode/cli/releases/download/v%{version}/quicknode-cli-%{rust_target}.tar.xz
37+
Source1: https://github.com/quicknode/cli/releases/download/v%{version}/quicknode-cli-%{rust_target}.tar.xz.sha256
38+
39+
ExclusiveArch: x86_64 aarch64
40+
BuildRequires: coreutils
41+
BuildRequires: tar
42+
BuildRequires: xz
43+
44+
%description
45+
qn is a command-line interface for Quicknode, built around noun-verb
46+
commands that read naturally for both humans and agents. Manage endpoints,
47+
streams, webhooks, the KV store, teams, usage, and billing.
48+
49+
This package installs the prebuilt binary cargo-dist publishes upstream —
50+
the same SLSA-attested artifact that ships in crates.io, Homebrew, the
51+
GHCR Docker image, the AUR qn-bin package, and Debian .deb files.
52+
53+
%prep
54+
# Verify the tarball matches the sha256 sidecar from the release.
55+
# The sidecar's format is `<hex> *<filename>`; rewrite the filename to
56+
# point at the local SOURCES path so `sha256sum -c` works.
57+
expected_hash=$(awk '{print $1}' < %{SOURCE1})
58+
actual_hash=$(sha256sum %{SOURCE0} | awk '{print $1}')
59+
if [ "$expected_hash" != "$actual_hash" ]; then
60+
echo "Error: sha256 mismatch for %{SOURCE0}" >&2
61+
echo " expected: $expected_hash" >&2
62+
echo " actual: $actual_hash" >&2
63+
exit 1
64+
fi
65+
%setup -q -n quicknode-cli-%{rust_target}
66+
67+
%install
68+
install -Dm755 qn %{buildroot}%{_bindir}/qn
69+
install -Dm644 LICENSE %{buildroot}%{_licensedir}/%{name}/LICENSE
70+
install -Dm644 README.md %{buildroot}%{_docdir}/%{name}/README.md
71+
72+
%files
73+
%{_bindir}/qn
74+
%license LICENSE
75+
%doc README.md
76+
77+
%changelog
78+
* Thu Jun 11 2026 Quicknode <support@quicknode.com> - %{version}-1
79+
- Automated build from the GitHub Release upstream.

0 commit comments

Comments
 (0)