Skip to content

Commit 7780902

Browse files
committed
feat(ci): split release pipeline into prepare and publish stages
Tagging a release now runs a Release Prepare workflow that runs the full test suite and builds server binaries once; a separate manual Release workflow consumes those artifacts by run ID to publish crates, push Docker images, cut the GitHub Release, and notify Discord as independently re-runnable stages. Version-tag validation is factored into a shared reusable workflow, and Cargo.toml version stamping moves into scripts/ci/stamp_version.sh so both entry points share one implementation. The Dockerfile gains a binary-handoff stage so the release pipeline can build images from an already-compiled binary instead of recompiling inside the build. CI also switches to an opt-in `run-ci` PR label so prose-only PRs no longer trigger the full suite, and several actions are bumped to their latest major versions.
1 parent 0803d82 commit 7780902

9 files changed

Lines changed: 440 additions & 208 deletions

File tree

.github/labels.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@
8181
description: "Working as intended or out of scope"
8282

8383
# ── cross-cutting flags ────────────────────────────────────────────────────
84+
- name: "run-ci"
85+
color: "0e8a16"
86+
description: "Opt this PR into the full test suite; re-add to force a re-run"
8487
- name: "regression"
8588
color: "b60205"
8689
description: "Worked on an earlier build; broke since"

.github/workflows/ci.yml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1-
# CI — thin wrapper that calls the reusable test workflow.
1+
# CI — opt-in wrapper that calls the reusable test workflow.
2+
#
3+
# The suite is a ~10-minute cold compile of the whole workspace. It does not
4+
# run on a pull request unless the PR carries the `run-ci` label, so prose-only
5+
# PRs cost nothing. Nothing in the `main` ruleset requires this check, so an
6+
# unlabelled PR stays mergeable — labelling is the author's judgement call.
7+
#
8+
# To run it:
9+
# - add the `run-ci` label to the PR (remove + re-add to force a re-run), or
10+
# - Actions → CI → Run workflow, against any branch.
11+
#
12+
# While the label is on, every subsequent push to the PR re-runs the suite.
213

314
name: CI
415

516
on:
617
pull_request:
718
branches: [main]
8-
types: [opened, synchronize, reopened, ready_for_review]
19+
types: [labeled, synchronize, reopened, ready_for_review]
920
workflow_dispatch:
10-
workflow_call:
1121

1222
concurrency:
1323
group: ci-${{ github.ref }}
@@ -18,6 +28,12 @@ permissions:
1828

1929
jobs:
2030
test:
21-
if: github.event.pull_request.draft == false
2231
name: Test Suite
32+
# A manual workflow_dispatch always runs. Pull requests run only when opted
33+
# in via the label, and never while the PR is a draft. (The release path has
34+
# its own gate: release-prepare.yml calls test.yml directly.)
35+
if: >-
36+
github.event_name != 'pull_request' ||
37+
(contains(github.event.pull_request.labels.*.name, 'run-ci') &&
38+
github.event.pull_request.draft != true)
2339
uses: ./.github/workflows/test.yml

.github/workflows/labels.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ jobs:
1515
sync:
1616
runs-on: ubuntu-latest
1717
steps:
18-
- uses: actions/checkout@v4
19-
- uses: crazy-max/ghaction-github-labeler@v5
18+
- uses: actions/checkout@v7
19+
- uses: crazy-max/ghaction-github-labeler@v6
2020
with:
2121
yaml-file: .github/labels.yml
2222
# Set to true only after confirming the manifest is complete —
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Tag gate for a releasable build. Pushing a v* tag validates the tag, runs the
2+
# full test suite, and builds the server binaries once. Nothing is published and
3+
# nothing leaves the repo.
4+
#
5+
# The manual `Release` workflow then consumes THIS run's binary artifacts by run
6+
# ID — for both the GitHub Release and the Docker image — so a failure in any
7+
# distribution stage can be fixed and retried without repeating the tests or the
8+
# compile.
9+
#
10+
# Artifacts are retained 14 days: that is the window in which a `Release` run can
11+
# still resume from this prepare run.
12+
13+
name: Release Prepare
14+
run-name: Release Prepare ${{ github.ref_name }}
15+
16+
on:
17+
push:
18+
tags:
19+
- "v*"
20+
21+
concurrency:
22+
group: release-prepare-${{ github.ref_name }}
23+
cancel-in-progress: false
24+
25+
permissions:
26+
contents: read
27+
28+
env:
29+
CARGO_TERM_COLOR: always
30+
31+
jobs:
32+
validate-version:
33+
uses: ./.github/workflows/release-validate.yml
34+
with:
35+
ref: ${{ github.ref_name }}
36+
37+
# Full lint + fast suite + 3-node cluster suite. Calls test.yml directly
38+
# rather than ci.yml, whose opt-in label gate only makes sense on a PR.
39+
ci:
40+
name: CI Gate
41+
needs: validate-version
42+
uses: ./.github/workflows/test.yml
43+
44+
build-server:
45+
name: Build server (${{ matrix.label }})
46+
needs: [validate-version, ci]
47+
runs-on: ${{ matrix.runs-on }}
48+
strategy:
49+
fail-fast: false
50+
matrix:
51+
include:
52+
- runs-on: ubuntu-latest
53+
target: x86_64-unknown-linux-gnu
54+
label: linux-x64
55+
- runs-on: ubuntu-24.04-arm
56+
target: aarch64-unknown-linux-gnu
57+
label: linux-arm64
58+
steps:
59+
- uses: actions/checkout@v7
60+
61+
- name: Install Rust
62+
uses: dtolnay/rust-toolchain@stable
63+
with:
64+
targets: ${{ matrix.target }}
65+
66+
- uses: Swatinem/rust-cache@v2
67+
with:
68+
prefix-key: server-${{ matrix.target }}
69+
70+
- name: Install system deps
71+
run: |
72+
sudo apt-get update
73+
sudo apt-get install -y --no-install-recommends \
74+
cmake clang libclang-dev pkg-config protobuf-compiler perl \
75+
libcurl4-openssl-dev libsasl2-dev
76+
77+
- name: Set version from tag
78+
run: bash scripts/ci/stamp_version.sh "${{ needs.validate-version.outputs.version }}"
79+
80+
- name: Build server binary
81+
run: cargo build -p nodedb --release --target ${{ matrix.target }}
82+
83+
- name: Package
84+
run: |
85+
cd target/${{ matrix.target }}/release
86+
chmod +x nodedb
87+
tar czf nodedb-${{ needs.validate-version.outputs.version }}-${{ matrix.label }}.tar.gz nodedb
88+
ls -lh nodedb-*.tar.gz
89+
90+
# Two artifacts per arch: the tarball ships in the GitHub Release, the
91+
# bare binary is the Docker build context. Same bytes, different shape.
92+
- uses: actions/upload-artifact@v7
93+
with:
94+
name: server-${{ matrix.label }}
95+
path: target/${{ matrix.target }}/release/nodedb-*.tar.gz
96+
retention-days: 14
97+
98+
- uses: actions/upload-artifact@v7
99+
with:
100+
name: binary-${{ matrix.label }}
101+
path: target/${{ matrix.target }}/release/nodedb
102+
retention-days: 14
103+
104+
summary:
105+
name: Release instructions
106+
needs: [validate-version, build-server]
107+
runs-on: ubuntu-latest
108+
steps:
109+
- name: Write summary
110+
env:
111+
VERSION: ${{ needs.validate-version.outputs.version }}
112+
REPO: ${{ github.repository }}
113+
RUN_ID: ${{ github.run_id }}
114+
run: |
115+
{
116+
echo "## Prepared v${VERSION}"
117+
echo
118+
echo "Tests passed and both server binaries are built. Publish with:"
119+
echo
120+
echo '```sh'
121+
echo "gh workflow run release.yml -R ${REPO} \\"
122+
echo " -f tag=v${VERSION} \\"
123+
echo " -f prepare_run_id=${RUN_ID}"
124+
echo '```'
125+
echo
126+
echo "If one stage fails, re-run the same command with only the"
127+
echo "remaining stages enabled (\`-f publish_crates=false\`, etc.)."
128+
echo "Nothing is recompiled."
129+
} >> "$GITHUB_STEP_SUMMARY"
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Reusable tag/version validation. Called by release-prepare.yml (from the
2+
# pushed tag) and release.yml (from the dispatched tag), so the two entry
3+
# points can never disagree about what a tag means.
4+
5+
name: Release Validate
6+
7+
on:
8+
workflow_call:
9+
inputs:
10+
ref:
11+
description: "Git ref to validate (a v* tag)"
12+
required: true
13+
type: string
14+
outputs:
15+
version:
16+
description: "Tag version without the leading v"
17+
value: ${{ jobs.validate.outputs.version }}
18+
is_full_release:
19+
description: "true when the version carries no prerelease suffix"
20+
value: ${{ jobs.validate.outputs.is_full_release }}
21+
22+
permissions:
23+
contents: read
24+
25+
jobs:
26+
validate:
27+
name: Validate Version Tag
28+
runs-on: ubuntu-latest
29+
outputs:
30+
version: ${{ steps.version.outputs.version }}
31+
is_full_release: ${{ steps.version.outputs.is_full_release }}
32+
steps:
33+
- uses: actions/checkout@v7
34+
with:
35+
ref: ${{ inputs.ref }}
36+
37+
- name: Install Rust
38+
uses: dtolnay/rust-toolchain@stable
39+
40+
- name: Validate tag against Cargo.toml
41+
id: version
42+
env:
43+
TAG: ${{ inputs.ref }}
44+
run: |
45+
TAG_VERSION="${TAG#v}"
46+
47+
CARGO_VERSION=$(cargo metadata --no-deps --format-version=1 \
48+
| jq -r '.packages[] | select(.name == "nodedb-types") | .version')
49+
CARGO_BASE=$(echo "$CARGO_VERSION" | grep -oP '^\d+\.\d+\.\d+')
50+
51+
echo "Tag version: $TAG_VERSION"
52+
echo "Cargo.toml version: $CARGO_VERSION"
53+
echo "Cargo.toml base: $CARGO_BASE"
54+
55+
if [[ ! "$TAG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-[a-zA-Z]+\.[0-9]+)?$ ]]; then
56+
echo "::error::Invalid tag format '$TAG'. Expected: vX.Y.Z or vX.Y.Z-label.N"
57+
exit 1
58+
fi
59+
60+
TAG_BASE="${BASH_REMATCH[1]}"
61+
62+
if [[ "$TAG_BASE" != "$CARGO_BASE" ]]; then
63+
echo "::error::Base version mismatch! Tag '$TAG_BASE' != Cargo.toml '$CARGO_BASE'"
64+
exit 1
65+
fi
66+
67+
# Full release = no hyphen suffix (v0.0.1, not v0.0.1-beta.1)
68+
if [[ "$TAG_VERSION" == *-* ]]; then
69+
echo "is_full_release=false" >> "$GITHUB_OUTPUT"
70+
else
71+
echo "is_full_release=true" >> "$GITHUB_OUTPUT"
72+
fi
73+
74+
echo "version=$TAG_VERSION" >> "$GITHUB_OUTPUT"

0 commit comments

Comments
 (0)