diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml new file mode 100644 index 0000000..73c7616 --- /dev/null +++ b/.github/workflows/build-binaries.yml @@ -0,0 +1,167 @@ +# The ONE place dig-node's cross-OS binary build lives. It is a REUSABLE workflow +# (`on: workflow_call` only — it never fires on its own): both release paths call it, so the +# OS/arch matrix + toolchain setup are defined exactly once (DRY, CLAUDE.md §2.5): +# +# * release.yml — the STABLE `vX.Y.Z` tag builder, and +# * nightly-release.yml — the NIGHTLY pre-release channel. +# +# The caller passes the version string stamped into each artifact's filename and, optionally, the +# git ref to build. The job builds the self-contained `dig-node` service binary AND its first-class +# `dign` alias (issue #548) for every supported OS/arch, and uploads them as run artifacts the +# caller's publish job attaches to a GitHub Release. +# +# DUAL ASSET NAMING (SPEC §11.2) — every per-OS/arch binary is published under TWO names so both +# downstream consumers resolve it with NO change on their side: +# * `dig-node---[.exe]` ← the CANONICAL name the dig-installer thin-shim +# PREFERS (its dig-node Repo stem is `dig-node`). +# * `dig-companion---[.exe]` ← the legacy name. apt.dig.net's packaging resolves +# the Linux binary by this exact template, and the installer keeps it as its pre-rename +# fallback. The binary itself is identical; only the filename differs. +# `dign` is a FIRST-CLASS alias binary (issue #548): a SEPARATE `[[bin]]` target sharing the SAME +# entrypoint, published alongside `dig-node` under its own stem `dign---[.exe]`. +# +# NO linux-arm64 asset is published: the Linux build graph pulls `openssl-sys` (via the Chia wallet +# SDK), so an aarch64-Linux cross-compile would also have to cross-compile OpenSSL — fragile, and +# no consumer requests it (SPEC §11.3). +# +# The gate runs fmt + clippy ONLY — a PR's own CI (ci.yml) already ran the full +# fmt/clippy/test/coverage set (`cargo llvm-cov nextest --workspace`) against the merged tree +# before merge (§2.4a). Re-running `cargo test` on the release path added nothing but a second +# chance for a flaky test to block a release that already passed its gate once (#488/#489); fmt + +# clippy stay because they are deterministic and a cheap fail-fast before spending build minutes. +name: Build binaries (reusable) + +on: + workflow_call: + inputs: + version: + description: "Version string stamped into each artifact filename (e.g. `1.2.3` or `1.2.3-nightly.20260714.abc1234`)." + type: string + required: true + ref: + description: "Git ref (tag / branch / SHA) to check out and build. Empty = the caller run's commit." + type: string + required: false + default: "" + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + # ENOSPC guard: the workspace compiles the full P2P stack (dig-nat/gossip/dht/pex/download) + + # wasmtime/cranelift + the chia wallet SDK, whose debug symbols blow the runner disk. Strip + # debuginfo from dev + test + release builds so a full build/clippy fits the CI disk. + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + CARGO_PROFILE_RELEASE_DEBUG: "0" + +jobs: + # Gate: fmt + clippy on Linux, once. A ref that doesn't pass the gate never produces release + # binaries. No test re-run here — see the header comment (#489). + check: + name: fmt + clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - name: Install Rust (stable + components) + run: | + rustup toolchain install stable --component rustfmt --component clippy + rustup default stable + rustc --version + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ runner.os }}-companion-check-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-companion-check- + - run: cargo fmt --all --check + - run: cargo clippy --all-targets --locked -- -D warnings + + build: + name: build (${{ matrix.out_name }}) + needs: check + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + bin: dig-node.exe + out_name: windows-x64.exe + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + bin: dig-node + out_name: linux-x64 + # Both macOS arches build on the fast Apple-silicon macos-14 runner; the Intel binary is + # CROSS-COMPILED there (host arm64 → target x86_64-apple-darwin). The only C/asm dep in + # the macOS graph is `ring` (via rustls); `openssl-sys` is cfg-gated to non-Apple targets, + # so there is no vendored-openssl cross-compile to worry about. + - os: macos-14 # Apple silicon (arm64) + target: aarch64-apple-darwin + bin: dig-node + out_name: macos-arm64 + - os: macos-14 # Apple silicon — cross-compiles the Intel binary + target: x86_64-apple-darwin + bin: dig-node + out_name: macos-x64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - name: Install Rust + target + shell: bash + run: | + rustup toolchain install stable + rustup default stable + rustup target add ${{ matrix.target }} + rustc --version + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ runner.os }}-companion-build-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-companion-build-${{ matrix.target }}- + - name: Build release binaries (dig-node + dign alias) + # Build BOTH bins — `dign` is the first-class alias (issue #548), published alongside + # `dig-node` under its own asset stem below. + run: cargo build --release --locked --target ${{ matrix.target }} --bin dig-node --bin dign + - name: Stage artifact (dual-named) + id: stage + # The caller's `version` is stamped into the filename verbatim: a plain `1.2.3` for a + # stable tag, or the synthesized `1.2.3-nightly.YYYYMMDD.` for a nightly. + shell: bash + run: | + VER="${{ inputs.version }}" + mkdir -p dist + SRC="target/${{ matrix.target }}/release/${{ matrix.bin }}" + test -f "$SRC" || { echo "binary not produced: $SRC"; exit 1; } + # On Windows out_name already ends in .exe; on unix there is no extension. Publish the + # SAME binary under both the canonical dig-node-* name and the legacy dig-companion-* + # name (apt's template + the installer's pre-rename fallback). See the header comment. + cp "$SRC" "dist/dig-node-${VER}-${{ matrix.out_name }}" + cp "$SRC" "dist/dig-companion-${VER}-${{ matrix.out_name }}" + # The `dign` first-class alias (issue #548): a SEPARATE binary that behaves byte-for-byte + # like `dig-node`, published under its own `dign-*` stem (same shape as `dig-node-*`) so + # the dig-installer resolves it via Repo::dign(). Its filename differs from `dig-node` + # only by the `dig-node`->`dign` substring (incl. the `.exe` suffix on Windows). + DIGN_BIN="${{ matrix.bin }}"; DIGN_BIN="${DIGN_BIN/dig-node/dign}" + DIGN_SRC="target/${{ matrix.target }}/release/${DIGN_BIN}" + test -f "$DIGN_SRC" || { echo "dign binary not produced: $DIGN_SRC"; exit 1; } + cp "$DIGN_SRC" "dist/dign-${VER}-${{ matrix.out_name }}" + ls -la dist + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: dig-node-${{ matrix.out_name }} + path: dist/* + if-no-files-found: error diff --git a/.github/workflows/changelog-tag.yml b/.github/workflows/changelog-tag.yml deleted file mode 100644 index 3e0ac7e..0000000 --- a/.github/workflows/changelog-tag.yml +++ /dev/null @@ -1,82 +0,0 @@ -# On merge to the default branch: regenerate CHANGELOG.md from Conventional Commits (git-cliff), -# commit it to the default branch, THEN tag that commit vX.Y.Z and push the tag. The changelog is -# therefore INCLUDED in the tag. The pushed tag triggers this repo's deploy/release workflow -# (wired `on: push: tags: ['v*']`). -# -# The tag is pushed with RELEASE_TOKEN (a PAT) — a tag pushed by the default GITHUB_TOKEN does NOT -# trigger downstream workflows (GitHub anti-recursion), which would break deploy-on-tag. The same -# PAT pushes the changelog commit (its identity must be allowed past branch protection — -# enforce_admins is off; the PAT is an org-admin/owner). See CLAUDE.md §3.6. -# -# Idempotent + loop-safe: no-op if the version's tag already exists; skips its own changelog commit. -name: Release - -on: - push: - branches: - - main - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: write - -jobs: - release: - name: Changelog + tag - if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} - - - name: Resolve version + skip if already tagged - id: ver - shell: bash - run: | - set -euo pipefail - pkg() { [ -f package.json ] && jq -r '.version // ""' package.json 2>/dev/null || echo ""; } - crg() { - [ -f Cargo.toml ] || { echo ""; return; } - python3 -c 'import tomllib; d=tomllib.load(open("Cargo.toml","rb")); v=d.get("package",{}).get("version") or d.get("workspace",{}).get("package",{}).get("version") or ""; print(v if isinstance(v,str) else "")' 2>/dev/null || echo "" - } - PV="$(pkg)"; CV="$(crg)"; VER="${PV:-$CV}" - if [ -z "$VER" ]; then echo "skip=true" >>"$GITHUB_OUTPUT"; echo "No version file — nothing to release."; exit 0; fi - TAG="v$VER" - if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null \ - || git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "skip=true" >>"$GITHUB_OUTPUT"; echo "Tag $TAG already exists — no-op."; exit 0 - fi - echo "skip=false" >>"$GITHUB_OUTPUT" - echo "tag=$TAG" >>"$GITHUB_OUTPUT" - echo "Releasing $TAG" - - - name: Install git-cliff - if: steps.ver.outputs.skip == 'false' - uses: taiki-e/install-action@v2 - with: - tool: git-cliff - - - name: Generate changelog - if: steps.ver.outputs.skip == 'false' - run: git-cliff --config cliff.toml --tag "${{ steps.ver.outputs.tag }}" --output CHANGELOG.md - - - name: Commit changelog, tag, push - if: steps.ver.outputs.skip == 'false' - shell: bash - run: | - set -euo pipefail - git config user.name "dig-release-bot" - git config user.email "release-bot@users.noreply.github.com" - git add CHANGELOG.md - # `chore(release):` prefix so this workflow skips its own push (loop guard above). - git commit -m "chore(release): ${{ steps.ver.outputs.tag }}" || echo "changelog unchanged" - git tag -a "${{ steps.ver.outputs.tag }}" -m "Release ${{ steps.ver.outputs.tag }}" - git push origin "HEAD:main" - git push origin "${{ steps.ver.outputs.tag }}" - echo "Pushed changelog commit + ${{ steps.ver.outputs.tag }} — deploy-on-tag will fire." diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml new file mode 100644 index 0000000..f844446 --- /dev/null +++ b/.github/workflows/nightly-release.yml @@ -0,0 +1,359 @@ +# ───────────────────────────────────────────────────────────────────────────────────────────── +# The nightly + manual-dispatch RELEASE ORCHESTRATOR (dig_ecosystem #590/#592). Copied from the +# ecosystem's REFERENCE nightlies implementation (DIG-Network/dig-updater) and adapted to +# dig-node's build. The normative contract is SPEC.md §11; the ops runbook is runbooks/release.md. +# +# It replaces the old "tag-and-release-on-every-merge-to-main" model. Releases are no longer cut +# per merge; they are batched to a nightly cron (+ manual dispatch), across two channels: +# +# • STABLE channel (job `stable`): the unchanged changelog+tag logic, now driven by the cron +# instead of push-to-main. It reads the version from Cargo.toml and cuts `vX.Y.Z` ONLY when +# that tag does not already exist — i.e. only when the version file was bumped since the last +# release (an unchanged version = the tag exists = a no-op). Cutting the tag = git-cliff +# regenerates CHANGELOG.md, commits it to main, tags that commit `vX.Y.Z`, and pushes both +# with RELEASE_TOKEN. The pushed `v*` tag then fires the STABLE binary build+publish +# (release.yml). This is the SAME stable path as before — only its trigger changed. +# +# • NIGHTLY channel (jobs `nightly-meta` → `nightly-build` → `nightly-publish`): every night it +# builds `main` HEAD and publishes a GitHub PRE-release (never `latest`) under a dated tag +# `nightly-YYYYMMDD` plus a force-moved rolling `nightly` tag, so there is ALWAYS a fresh +# nightly regardless of whether the version was bumped. It synthesizes a semver-prerelease +# version `X.Y.Z-nightly.YYYYMMDD.` at BUILD time (nothing is committed) and prunes +# dated nightlies down to a retention window. +# +# Manual invocation (`workflow_dispatch`) drives EITHER channel on demand — choose `stable`, +# `nightly`, or `both`; `force` re-cuts a stable release even when the version is unchanged. +# +# RELEASE_TOKEN posture (both channels): a tag pushed by the default GITHUB_TOKEN does NOT trigger +# downstream workflows (GitHub anti-recursion) and GITHUB_TOKEN cannot push past branch protection, +# so releases use the RELEASE_TOKEN org PAT. If RELEASE_TOKEN is absent, every channel NO-OPS with +# a clear warning instead of half-releasing — add the org secret to enable releases. +# +# 60-day auto-disable (GitHub platform behavior, not this workflow's): GitHub disables a +# `schedule:` trigger after 60 days with NO repo activity on a public repo, with no auto-re-enable +# — and since #590 removed the push-to-main trigger, this cron is the ONLY automatic release +# path. If nightlies silently stop, check `gh api repos///actions/workflows/ +# nightly-release.yml --jq .state` and re-enable with `gh workflow enable nightly-release.yml` +# (see runbooks/release.md + SPEC.md §11.1). +# ───────────────────────────────────────────────────────────────────────────────────────────── +name: Nightly + stable release + +on: + # Midnight UTC — GitHub Actions cron is ALWAYS UTC. GitHub may delay a top-of-hour cron under + # load; that is acceptable here (a nightly a little late is fine, and the stable channel is + # idempotent — a delayed or skipped run just re-checks next time). + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + inputs: + channel: + description: "Which channel(s) to release." + type: choice + options: [both, stable, nightly] + default: both + force: + description: "Force a STABLE release even if the version is unchanged (re-cut the tag)." + type: boolean + default: false + +concurrency: + # Serialize release runs so an overlapping cron + manual dispatch can never race on tags/releases. + group: nightly-release + cancel-in-progress: false + +permissions: + contents: write + +jobs: + # ───────────────────────────────── STABLE channel ───────────────────────────────── + # The converted on-merge tagger: version-detect → skip-if-already-tagged → git-cliff changelog + # → commit + tag + push (RELEASE_TOKEN). The pushed `v*` tag fires release.yml (the binary build). + stable: + name: Stable — changelog + tag + # Run on the schedule (both channels) or when dispatch selects stable/both. The + # `!startsWith(...chore(release):...)` guard is preserved from the on-merge era: it is INERT on + # schedule/dispatch (there is no `head_commit`), but it keeps the release changelog commit + # self-labelled so the loop stays safe even if a push trigger is ever re-introduced. + if: >- + ${{ + !startsWith(github.event.head_commit.message, 'chore(release):') && + (github.event_name == 'schedule' || inputs.channel == 'stable' || inputs.channel == 'both') + }} + runs-on: ubuntu-latest + steps: + - name: Require RELEASE_TOKEN (else no-op) + id: token + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + if [ -z "$RELEASE_TOKEN" ]; then + echo "::warning::RELEASE_TOKEN is not set on this repo. The stable changelog+tag release is a NO-OP until the org-level PAT secret is added (see runbooks/release.md + SPEC.md §11). Nothing will be released." + echo "present=false" >> "$GITHUB_OUTPUT" + else + echo "present=true" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + if: steps.token.outputs.present == 'true' + uses: actions/checkout@v4 + with: + # Pin to `main` explicitly (opus review of #592): a schedule/dispatch run already + # defaults to the branch it ran on, but pinning makes the `HEAD:main` push below + # unambiguous — the changelog commit + tag can only ever land on `main` HEAD. + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Resolve version + skip if already tagged + if: steps.token.outputs.present == 'true' + id: ver + shell: bash + env: + FORCE: ${{ inputs.force }} + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + set -euo pipefail + crg() { + [ -f Cargo.toml ] || { echo ""; return; } + python3 -c 'import tomllib; d=tomllib.load(open("Cargo.toml","rb")); v=d.get("package",{}).get("version") or d.get("workspace",{}).get("package",{}).get("version") or ""; print(v if isinstance(v,str) else "")' 2>/dev/null || echo "" + } + VER="$(crg)" + if [ -z "$VER" ]; then echo "skip=true" >>"$GITHUB_OUTPUT"; echo "No version file — nothing to release."; exit 0; fi + TAG="v$VER" + # The idempotency keystone: an unchanged version means `vX.Y.Z` already exists, so the + # run is a no-op — UNLESS `force` was requested (a manual re-cut of the same version). + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null \ + || git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + if [ "$FORCE" = "true" ]; then + # Supply-chain guard: a PUBLISHED release is a shipped artifact — force-moving its + # tag onto a different commit would silently overwrite that version's binaries with + # code nobody reviewed under it. Only allow the re-cut when it is SAFE: the tag + # already points at the commit we're about to build (a legitimate "the build + # failed, re-fire release.yml" retry), or the tag has no published release yet (a + # repair of a bare/failed tag). Resolve the tag's commit locally first, falling back + # to the remote (mirrors the existence check above, which is dual local/remote too). + TAG_COMMIT="$(git rev-parse -q --verify "refs/tags/$TAG^{commit}" 2>/dev/null || true)" + if [ -z "$TAG_COMMIT" ]; then + TAG_COMMIT="$(git ls-remote origin "refs/tags/$TAG^{}" 2>/dev/null | cut -f1)" + fi + if [ -z "$TAG_COMMIT" ]; then + TAG_COMMIT="$(git ls-remote origin "refs/tags/$TAG" 2>/dev/null | cut -f1)" + fi + HEAD_COMMIT="$(git rev-parse HEAD)" + IS_PUBLISHED_RELEASE="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft == false' 2>/dev/null || echo "false")" + if [ "$IS_PUBLISHED_RELEASE" = "true" ] && [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then + echo "::error::refusing to force-move $TAG — a PUBLISHED release already exists for it and the tag points at a different commit ($TAG_COMMIT) than this run's target ($HEAD_COMMIT). force is only for re-cutting the SAME commit (a failed-build retry) or repairing a tag with no published release. Bump the version instead of overwriting a shipped release." + exit 1 + fi + echo "Tag $TAG already exists but force=true — re-cutting (the tag will be moved)." + echo "force=true" >>"$GITHUB_OUTPUT" + else + echo "skip=true" >>"$GITHUB_OUTPUT"; echo "Tag $TAG already exists — no-op."; exit 0 + fi + fi + echo "skip=false" >>"$GITHUB_OUTPUT" + echo "tag=$TAG" >>"$GITHUB_OUTPUT" + echo "Releasing $TAG" + + - name: Install git-cliff + if: steps.token.outputs.present == 'true' && steps.ver.outputs.skip == 'false' + uses: taiki-e/install-action@v2 + with: + tool: git-cliff + + - name: Generate changelog + if: steps.token.outputs.present == 'true' && steps.ver.outputs.skip == 'false' + run: git-cliff --config cliff.toml --tag "${{ steps.ver.outputs.tag }}" --output CHANGELOG.md + + - name: Commit changelog, tag, push + if: steps.token.outputs.present == 'true' && steps.ver.outputs.skip == 'false' + shell: bash + env: + FORCE_TAG: ${{ steps.ver.outputs.force }} + run: | + set -euo pipefail + git config user.name "dig-release-bot" + git config user.email "release-bot@users.noreply.github.com" + git add CHANGELOG.md + # `chore(release):` prefix labels the release commit so a re-introduced push trigger would + # skip it (the loop guard on the job `if:`). + git commit -m "chore(release): ${{ steps.ver.outputs.tag }}" || echo "changelog unchanged" + # A `force` re-cut moves an existing tag onto the fresh changelog commit; a normal cut + # creates a new tag. Main is NEVER force-pushed. NOTE (opus review of #592): a force-moved + # tag breaks git tag-immutability for that version — acceptable ONLY for the guarded + # retry/repair cases above. dig-node updates are gated by the dig-updater signed feed + # (the beacon verifies an Ed25519 signature over the update descriptor before applying), + # so that SIGNATURE — not the mutable git tag — is the integrity layer consumers rely on. + if [ "$FORCE_TAG" = "true" ]; then + git tag -f -a "${{ steps.ver.outputs.tag }}" -m "Release ${{ steps.ver.outputs.tag }}" + git push origin "HEAD:main" + git push -f origin "${{ steps.ver.outputs.tag }}" + else + git tag -a "${{ steps.ver.outputs.tag }}" -m "Release ${{ steps.ver.outputs.tag }}" + git push origin "HEAD:main" + git push origin "${{ steps.ver.outputs.tag }}" + fi + echo "Pushed changelog commit + ${{ steps.ver.outputs.tag }} — release.yml (stable build) will fire." + + # ───────────────────────────────── NIGHTLY channel ───────────────────────────────── + # 1) meta: gate on the token + synthesize the nightly version and tags (nothing is committed). + nightly-meta: + name: Nightly — resolve version + if: >- + ${{ github.event_name == 'schedule' || inputs.channel == 'nightly' || inputs.channel == 'both' }} + runs-on: ubuntu-latest + outputs: + token_present: ${{ steps.token.outputs.present }} + version: ${{ steps.v.outputs.version }} + dated_tag: ${{ steps.v.outputs.dated_tag }} + title: ${{ steps.v.outputs.title }} + steps: + - name: Require RELEASE_TOKEN (else no-op) + id: token + env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + if [ -z "$RELEASE_TOKEN" ]; then + echo "::warning::RELEASE_TOKEN is not set on this repo. The nightly pre-release is a NO-OP until the org-level PAT secret is added (see runbooks/release.md + SPEC.md §11)." + echo "present=false" >> "$GITHUB_OUTPUT" + else + echo "present=true" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + if: steps.token.outputs.present == 'true' + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Synthesize the nightly version + tags + if: steps.token.outputs.present == 'true' + id: v + shell: bash + run: | + set -euo pipefail + crg() { + python3 -c 'import tomllib; d=tomllib.load(open("Cargo.toml","rb")); v=d.get("package",{}).get("version") or d.get("workspace",{}).get("package",{}).get("version") or ""; print(v if isinstance(v,str) else "")' + } + BASE="$(crg)" # e.g. 0.31.2 (the current Cargo.toml version) + DATE="$(date -u +%Y%m%d)" # e.g. 20260714 (UTC) + DATE_DASH="$(date -u +%Y-%m-%d)" # e.g. 2026-07-14 (UTC, for the human title) + SHA="$(git rev-parse --short HEAD)" + # Semver PRERELEASE version — sorts BELOW the plain `X.Y.Z`, so a nightly never outranks + # the stable release of the same version. Built at CI time; never committed. + echo "version=${BASE}-nightly.${DATE}.${SHA}" >> "$GITHUB_OUTPUT" + echo "dated_tag=nightly-${DATE}" >> "$GITHUB_OUTPUT" + echo "title=Nightly ${DATE_DASH} (${SHA})" >> "$GITHUB_OUTPUT" + + # 2) build: the SAME reusable cross-OS build the stable release uses, stamped with the nightly + # version. Skipped (with the whole channel) when the token is absent. + nightly-build: + name: Nightly — build + needs: nightly-meta + if: needs.nightly-meta.outputs.token_present == 'true' + uses: ./.github/workflows/build-binaries.yml + with: + version: ${{ needs.nightly-meta.outputs.version }} + ref: "" # build this run's commit (main HEAD) + + # 3) publish: attach the built binaries to a dated + rolling PRE-release, then prune old + # nightlies. Uses RELEASE_TOKEN for git tag + gh release operations (same identity as stable). + nightly-publish: + name: Nightly — publish + prune + needs: [nightly-meta, nightly-build] + if: needs.nightly-meta.outputs.token_present == 'true' + runs-on: ubuntu-latest + env: + # How many dated `nightly-YYYYMMDD` pre-releases to retain; older ones (and their tags) are + # pruned each run. The rolling `nightly` and all `v*` stable releases are NEVER pruned. + KEEP_NIGHTLIES: 14 + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + REPO: ${{ github.repository }} + DATED_TAG: ${{ needs.nightly-meta.outputs.dated_tag }} + TITLE: ${{ needs.nightly-meta.outputs.title }} + steps: + - name: Checkout (with push credentials) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Flatten artifacts + run: | + mkdir -p release + find artifacts -type f -exec cp {} release/ \; + ls -la release + + - name: Move nightly tags (dated + rolling) to this build + shell: bash + run: | + set -euo pipefail + git config user.name "dig-release-bot" + git config user.email "release-bot@users.noreply.github.com" + # Force so a same-day re-run (or the moving rolling pointer) is idempotent. Neither + # `nightly-*` nor `nightly` matches release.yml's `v*` trigger, so no stable build fires. + git push -f origin "HEAD:refs/tags/${DATED_TAG}" + git push -f origin "HEAD:refs/tags/nightly" + + - name: Publish the dated + rolling pre-releases + shell: bash + run: | + set -euo pipefail + NOTES="Automated nightly pre-release built from \`main\` HEAD (${TITLE}). Version \`${{ needs.nightly-meta.outputs.version }}\`. Pre-release — NOT a stable release; for alpha testers tracking the nightly channel." + + # Ensure a prerelease exists at the tag, keep its metadata current, and replace ALL of + # its assets with THIS run's binaries (filenames carry the date+sha, so a stale asset + # would otherwise linger — clear then upload). Applied to both the immutable dated tag + # and the rolling `nightly` pointer. + # + # Delete-then-upload (not `gh release upload --clobber`) is intentional, not an + # oversight: each night's filenames embed that day's date+shortsha, so `--clobber` + # (which only overwrites SAME-named assets) would never remove the PREVIOUS night's + # differently-named leftovers from the rolling `nightly` release — a stale-then-fresh + # asset pair would accumulate forever. This does leave a brief assetless window on the + # release mid-step; acceptable for this nightly PRE-release channel (stable releases + # never take this path). + refresh_release() { + local tag="$1" + gh release view "$tag" --repo "$REPO" >/dev/null 2>&1 \ + || gh release create "$tag" --repo "$REPO" --verify-tag \ + --prerelease --latest=false --title "$TITLE" --notes "$NOTES" + gh release edit "$tag" --repo "$REPO" --prerelease --latest=false --title "$TITLE" >/dev/null + gh release view "$tag" --repo "$REPO" --json assets --jq '.assets[].name' \ + | while read -r asset; do + [ -n "$asset" ] && gh release delete-asset "$tag" "$asset" --repo "$REPO" --yes + done + gh release upload "$tag" release/* --repo "$REPO" + } + + refresh_release "${DATED_TAG}" + refresh_release "nightly" + echo "Published ${DATED_TAG} + moved rolling nightly." + + - name: Prune old nightlies (keep newest KEEP_NIGHTLIES + the rolling tag) + shell: bash + run: | + set -euo pipefail + # `gh release list` runs as its own un-swallowed statement — a real listing failure + # (auth/API/network) reds this step per `set -e`, rather than being masked into a silent + # empty-prune. Only the DOWNSTREAM grep's exit 1 (its normal "no dated nightlies matched" + # on a quiet/new repo) is tolerated — a listing failure surfaces, a genuine empty match + # does not. + ALL_TAGS="$(gh release list --repo "$REPO" --limit 1000 --json tagName --jq '.[].tagName')" + # Dated nightly tags ONLY: `nightly-YYYYMMDD` sorts chronologically as text, so `sort -r` + # is newest-first. The rolling `nightly` and every `v*` stable tag are excluded by the + # pattern and are never touched. + mapfile -t dated < <( + printf '%s\n' "$ALL_TAGS" | grep -E '^nightly-[0-9]{8}$' | sort -r || [ $? -eq 1 ] + ) + # Everything past the newest KEEP_NIGHTLIES is pruned — release AND git tag together. + for tag in "${dated[@]:$KEEP_NIGHTLIES}"; do + echo "Pruning old nightly: $tag" + gh release delete "$tag" --repo "$REPO" --yes --cleanup-tag + done + echo "Retention: kept newest $KEEP_NIGHTLIES dated nightlies + the rolling nightly." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 780d8d6..9dfaf93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,218 +1,64 @@ -name: Release dig-node - -# On a version tag (v*), build the self-contained `dig-node` service binary for -# every supported OS/arch and attach them to a GitHub Release. Mirrors the -# tag-driven, per-OS matrix pattern of digstore's release workflow, but the artifact -# here is the plain installable binary (no Tauri installer): users download it and -# run `dig-node install`. -# -# DUAL ASSET NAMING — every per-OS/arch binary is published under TWO names so both -# downstream consumers resolve it with NO change on their side: -# -# * `dig-node---[.exe]` ← the CANONICAL name the dig-installer -# thin-shim PREFERS (its dig-node Repo stem is `dig-node`; see -# dig-installer/src/asset.rs::select_asset + release.rs::Repo::dig_node). -# * `dig-companion---[.exe]` ← the legacy name. apt.dig.net's -# packaging resolves the Linux binary by this exact template -# (`dig-companion-{ver}-linux-{x64,arm64}`, ARCHIVE_BIN_PATH="" = bare binary; -# see apt.dig.net/packaging/config.sh::PKG_dig_node_ASSET_TEMPLATE) and the -# installer keeps it as its pre-rename fallback (Repo::dig_node_legacy). -# -# The binary itself is identical; only the filename differs. The repo + Cargo binary -# were renamed dig-companion → dig-node, so the build now emits `dig-node` and the -# CANONICAL asset matches the produced binary; the legacy `dig-companion-*` asset is -# still published (a copy of the same bytes) so apt.dig.net + the installer fallback -# keep resolving byte-exact across the rename without editing the consumer repos. -# -# `dign` is a FIRST-CLASS alias binary for `dig-node` (issue #548, mirroring how `digs` -# aliases `digstore` #434): a SEPARATE `[[bin]]` target sharing the SAME entrypoint, so -# `dign ` behaves byte-for-byte like `dig-node `. It is built + published -# ALONGSIDE `dig-node` under its own stem `dign---[.exe]` (identical shape -# to `dig-node-*`), which the dig-installer resolves via `Repo::dign()` (a follow-up). -# -# Per OS/arch (each emitted under both stems above): -# * windows-x64 (x86_64-pc-windows-msvc, windows-latest) -# * linux-x64 (x86_64-unknown-linux-gnu, ubuntu-latest) -# * macos-arm64 (aarch64-apple-darwin, macos-14) -# * macos-x64 (x86_64-apple-darwin, macos-14, cross-compiled — see below) +# STABLE binary release. On a `vX.Y.Z` tag (cut by the nightly-release orchestrator's stable job +# — nightly-release.yml — either from the midnight cron detecting a version bump or from a manual +# `workflow_dispatch`), this workflow builds the `dig-node` service binary + its `dign` alias for +# every OS/arch (via the reusable build workflow) and publishes them to a STABLE GitHub Release: +# `prerelease: false`, marked `latest`. Every per-OS/arch binary is published under BOTH the +# canonical `dig-node-*` and legacy `dig-companion-*` names (SPEC §11.2). The changelog is already +# inside the tag (the orchestrator committed it before tagging), so the notes carry the changelog. # -# NO linux-arm64 asset is published: the active Linux build graph pulls in -# `openssl-sys` (via the Chia wallet SDK `datalayer-driver` + `native-tls`, NOT via -# reqwest, which uses rustls here), so an aarch64-Linux cross-compile would also have -# to cross-compile OpenSSL — fragile and slow. No consumer requests it today: the -# dig-installer's Linux os_arch_tokens are x64-only and it REJECTS an `arm64`/ -# `aarch64` token as a competing arch, and apt.dig.net treats arm64 as best-effort -# (build-deb.sh resolves the asset, finds none, and SKIPS arm64 non-fatally). Revisit -# if/when an arm64-Linux consumer appears (would need vendored/cross OpenSSL). -# -# A push to main also builds (no publish) so a broken build is caught before tagging. -# -# The `check` gate below runs fmt + clippy ONLY (#489) — it deliberately does NOT re-run the -# test suite. The PR's own required CI (`ci.yml`) already ran fmt/clippy/tests/coverage against -# this exact merged tree before merge (§2.4a); re-running `cargo test` here on the tag/main-push -# path added nothing but a second chance for a flaky test to block a release that had already -# passed its gate once (the #488 v1.96.0 incident). fmt/clippy stay: they are deterministic -# (never flaky) and are a cheap fail-fast before spending 4 platforms' worth of build minutes. +# This is intentionally tag-ONLY: merges to main no longer build or release here (dig_ecosystem +# #590 batches releases to the nightly cron + manual dispatch). Pre-merge coverage comes from +# ci.yml (fmt/clippy/test/coverage on every PR); daily main-HEAD build coverage comes from the +# nightly channel. A `workflow_dispatch` is kept as a manual "does main still build?" canary — it +# builds but does not publish (publish is gated on a tag ref). +name: Release (stable) on: push: tags: - "v*" - branches: [main] - paths: - - "src/**" - - "tests/**" - - "build.rs" - - "Cargo.toml" - - "Cargo.lock" - - ".github/workflows/release.yml" workflow_dispatch: -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - # ENOSPC guard (digstore CI lesson): the workspace now compiles the full P2P stack - # (dig-nat/gossip/dht/pex/download) + wasmtime/cranelift (via digstore-host/-stage) + - # the chia wallet SDK, whose debug symbols blow the runner disk. Strip debuginfo from - # dev + test builds and keep it out of release binaries, so a full workspace - # test/build fits the CI disk. - CARGO_PROFILE_DEV_DEBUG: "0" - CARGO_PROFILE_TEST_DEBUG: "0" - CARGO_PROFILE_RELEASE_DEBUG: "0" +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write jobs: - # --------------------------------------------------------------------------- - # Gate: fmt + clippy on Linux, once. A tag that doesn't pass the gate never - # produces release binaries. No test re-run here — see the header comment (#489). - # --------------------------------------------------------------------------- - check: - name: fmt + clippy + # Resolve the version string the artifacts are stamped with: `X.Y.Z` from a `vX.Y.Z` tag, or a + # `g` build id for the manual canary (which never publishes). + meta: + name: Resolve version runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} steps: - uses: actions/checkout@v4 with: persist-credentials: false - - name: Install Rust (stable + components) + - id: v + shell: bash run: | - rustup toolchain install stable --component rustfmt --component clippy - rustup default stable - rustc --version - - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-companion-check-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-companion-check- - - run: cargo fmt --all --check - - run: cargo clippy --all-targets --locked -- -D warnings - - run: cargo test --locked + if [ "$GITHUB_REF_TYPE" = "tag" ]; then + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "version=g$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + fi - # --------------------------------------------------------------------------- - # Build the binary per OS/arch. - # --------------------------------------------------------------------------- + # Build every OS/arch through the shared reusable workflow — the same build the nightly channel + # uses, so the two release paths can never diverge on how a binary is produced. build: - name: build (${{ matrix.out_name }}) - needs: check - strategy: - fail-fast: false - matrix: - include: - - os: windows-latest - target: x86_64-pc-windows-msvc - bin: dig-node.exe - out_name: windows-x64.exe - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - bin: dig-node - out_name: linux-x64 - # Both macOS arches build on the fast Apple-silicon macos-14 runner. - # The Intel binary is CROSS-COMPILED there (host arm64 → target - # x86_64-apple-darwin) rather than on a native macos-13 (Intel) - # runner: that Intel runner pool is chronically backlogged, so the - # macos-x64 job used to hang for hours (pending, never failing) and - # the release never completed. Cross-compiling needs no extra - # toolchain here — Apple's clang (Xcode on macos-14) targets both - # arches, the build steps are already `--target`-parameterized, and - # the only C/asm dependency in the macOS build graph is `ring` (via - # rustls), which the Apple toolchain assembles for x86_64 natively. - # `openssl-sys` is cfg-gated to non-Apple targets, so it is NOT in - # the macOS graph at all (TLS on macOS resolves to native-tls → - # Security.framework); there is no vendored-openssl cross-compile to - # worry about. Artifact names are unchanged so the dig-installer - # thin-shim + apt asset matchers still resolve them. - - os: macos-14 # Apple silicon (arm64) - target: aarch64-apple-darwin - bin: dig-node - out_name: macos-arm64 - - os: macos-14 # Apple silicon — cross-compiles the Intel binary - target: x86_64-apple-darwin - bin: dig-node - out_name: macos-x64 - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Install Rust + target - shell: bash - run: | - rustup toolchain install stable - rustup default stable - rustup target add ${{ matrix.target }} - rustc --version - - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry/index - ~/.cargo/registry/cache - ~/.cargo/git/db - target - key: ${{ runner.os }}-companion-build-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-companion-build-${{ matrix.target }}- - - name: Build release binaries (dig-node + dign alias) - # Build BOTH bins — `dign` is the first-class alias (issue #548, mirroring digs - # #434), published alongside `dig-node` under its own asset stem below. - run: cargo build --release --locked --target ${{ matrix.target }} --bin dig-node --bin dign - - name: Stage artifact (dual-named) - id: stage - shell: bash - run: | - VER="${GITHUB_REF_NAME#v}" - [ "$GITHUB_REF_TYPE" = "tag" ] || VER="g$(git rev-parse --short HEAD)" - mkdir -p dist - SRC="target/${{ matrix.target }}/release/${{ matrix.bin }}" - test -f "$SRC" || { echo "binary not produced: $SRC"; exit 1; } - # On Windows out_name already ends in .exe; on unix there is no extension. - # Publish the SAME binary under both the canonical dig-node-* name (dig- - # installer's preferred stem) and the legacy dig-companion-* name (apt's - # template + the installer's pre-rename fallback). See the header comment. - cp "$SRC" "dist/dig-node-${VER}-${{ matrix.out_name }}" - cp "$SRC" "dist/dig-companion-${VER}-${{ matrix.out_name }}" - # The `dign` first-class alias (issue #548): a SEPARATE binary that behaves - # byte-for-byte like `dig-node`, published under its own `dign-*` stem (same - # shape as `dig-node-*`) so the dig-installer resolves it via Repo::dign(). Its - # filename differs from `dig-node` only by the `dig-node`->`dign` substring - # (incl. the `.exe` suffix on Windows). - DIGN_BIN="${{ matrix.bin }}"; DIGN_BIN="${DIGN_BIN/dig-node/dign}" - DIGN_SRC="target/${{ matrix.target }}/release/${DIGN_BIN}" - test -f "$DIGN_SRC" || { echo "dign binary not produced: $DIGN_SRC"; exit 1; } - cp "$DIGN_SRC" "dist/dign-${VER}-${{ matrix.out_name }}" - echo "ver=$VER" >> "$GITHUB_OUTPUT" - ls -la dist - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: dig-node-${{ matrix.out_name }} - path: dist/* - if-no-files-found: error + name: Build + needs: meta + uses: ./.github/workflows/build-binaries.yml + with: + version: ${{ needs.meta.outputs.version }} + # Empty ref => build the tag/commit this run is on. + ref: "" - # --------------------------------------------------------------------------- - # One publish job after all builds, so there is no race to create the Release. - # Only on a tag push. - # --------------------------------------------------------------------------- + # Publish ONLY for a real tag — the manual-dispatch canary builds but stops here. publish: name: Publish GitHub Release needs: build @@ -230,9 +76,14 @@ jobs: mkdir -p release find artifacts -type f -exec cp {} release/ \; ls -la release - - name: Create / update the release and attach binaries + - name: Create / update the STABLE release and attach binaries uses: softprops/action-gh-release@v2 with: + # `prerelease: false` + `make_latest: true`: a stable release is the one that moves + # `latest`. Nightlies (nightly-release.yml) are always prerelease + never latest, so a + # nightly can never masquerade as this stable download. + prerelease: false + make_latest: "true" files: release/* generate_release_notes: true fail_on_unmatched_files: true diff --git a/Cargo.lock b/Cargo.lock index 595a9ee..1f075ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1966,7 +1966,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.31.1" +version = "0.31.2" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index 12fee45..2c0d617 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[workspace.package].version`), so it MUST be set here for a release to fire # (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own # independent versions — only the released binary tracks the workspace version. -version = "0.31.1" +version = "0.31.2" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index c5f3332..fa66d76 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1384,12 +1384,33 @@ the **shell** mints plus the cross-boundary codes a client must be able to branc ## 11. Release and CI contract -11.1. **Tag-driven releases.** Pushing a `v*` tag runs `.github/workflows/release.yml`: a gate job -(`cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked`) -that MUST pass before any binary is built, then a per-OS/arch build matrix, then a single publish -job attaching all binaries to the GitHub Release. A push to `main` touching -`src/**`/`tests/**`/`build.rs`/`Cargo.toml`/`Cargo.lock`/the workflow runs gate + build (no -publish). Doc-only commits do not trigger the workflow. +11.1. **Nightly cron + manual dispatch (NOT per-merge).** Releases are batched to a nightly cron +plus manual dispatch — NOT cut on every merge to `main` (dig_ecosystem #590/#592; the shape is +copied from the reference `dig-updater`). One orchestrator, `.github/workflows/nightly-release.yml`, +triggers ONLY on `schedule: cron '0 0 * * *'` (midnight UTC — GitHub cron is always UTC, and a +top-of-hour cron MAY be delayed under load, which is acceptable since both channels are idempotent) +and `workflow_dispatch` (inputs `channel` = `both`|`stable`|`nightly`, default `both`; `force` +boolean, default `false`). It MUST NOT trigger on `push` to `main`. + +- **Stable channel:** cuts a `vX.Y.Z` release when — and only when — the `[workspace.package].version` + in the root `Cargo.toml` has advanced beyond the newest `vX.Y.Z` tag (the skip-if-already-tagged + check IS the version-changed check). Cutting = `git-cliff` regenerates `CHANGELOG.md`, commits it + to `main` as `chore(release): vX.Y.Z`, tags THAT commit, and pushes commit + tag with + `RELEASE_TOKEN`. The pushed `v*` tag fires `release.yml` (§11.2/§11.3), which publishes a GitHub + Release with `prerelease: false` + `make_latest: true` — the ONLY release that moves `latest`. +- **Force re-cut (guarded).** `force: true` bypasses skip-if-tagged and re-cuts the current version + (moving the tag onto a fresh changelog commit; `main` is never force-pushed). It MUST be refused + — non-zero exit, clear error — when BOTH: (a) a PUBLISHED (non-draft) Release exists at the tag, + AND (b) the tag points at a commit DIFFERENT from the one this run would build (that would + overwrite shipped binaries with unreviewed code under the same version). Force MAY proceed for a + same-commit re-cut (failed-build retry) or a tag with no published release (a tag repair). A + force-moved tag breaks git tag-immutability; because dig-node updates are gated by the dig-updater + signed feed (an Ed25519 signature over the update descriptor, verified before apply), that + signature — not the mutable tag — is the integrity anchor. Ship new code by bumping the version. + +11.1a. **Doc-only commits never release** (the version is unchanged → the tag exists → the stable +job is a no-op). The manual-dispatch `workflow_dispatch` on `release.yml` is a build-only "does main +still build?" canary — it never publishes (publish is gated on a tag ref). 11.2. **Dual asset naming (HARD RULE).** Every per-OS/arch binary MUST be published under TWO filenames containing identical bytes: @@ -1412,6 +1433,32 @@ rejects arm64 tokens). 11.4. **Release hardening.** The release profile keeps `overflow-checks = true` (the read path does offset/length arithmetic over untrusted serialized input). +11.5. **Nightly channel.** Every night (and on demand) the orchestrator builds `main` HEAD for +every OS/arch and publishes a GitHub **pre-release** — so a fresh nightly always exists regardless +of a version bump. It synthesizes a build-time version `X.Y.Z-nightly.YYYYMMDD.` (nothing +is committed; as a semver prerelease it sorts BELOW the plain `X.Y.Z`), publishes under a dated tag +`nightly-YYYYMMDD` AND force-moves a rolling `nightly` tag, with `prerelease: true` and **never** +`latest`. Retention keeps the newest **14** dated nightlies plus the rolling `nightly`, pruning +older dated pre-releases AND their tags together (`gh release delete --cleanup-tag`); `v*` stable +tags/releases and the rolling `nightly` are NEVER pruned. Neither `nightly-*` nor `nightly` matches +`release.yml`'s `v*` trigger, so the nightly channel never fires the stable build. + +11.6. **Reusable build.** The cross-OS build lives once in `.github/workflows/build-binaries.yml` +(`on: workflow_call`, inputs `version` + `ref`). Both `release.yml` (stable) and the nightly channel +call it, so the two paths can never diverge on HOW a binary is produced — including the dual +`dig-node-*`/`dig-companion-*` naming (§11.2) and the `dign` alias. + +11.7. **RELEASE_TOKEN posture + 60-day cron caveat.** Releasing uses the `RELEASE_TOKEN` org PAT, +not `GITHUB_TOKEN` (a tag pushed by `GITHUB_TOKEN` does not trigger downstream workflows, and it +cannot push a changelog commit past branch protection). If `RELEASE_TOKEN` is absent, EVERY channel +NO-OPS with a clear `::warning::` — never a half-release. A `concurrency: nightly-release` group +(cancel-in-progress `false`) serializes runs. GitHub auto-disables a `schedule:` trigger after 60 +days with no repo activity on a public repo, with no auto-re-enable — and since this cron is now the +ONLY automatic release trigger, a quiet repo can silently stop releasing. Detect with +`gh api repos/DIG-Network/dig-node/actions/workflows/nightly-release.yml --jq .state` +(`disabled_inactivity` = auto-disabled) and recover with `gh workflow enable nightly-release.yml` +(see `runbooks/release.md`). + --- ## 12. Security properties (summary) diff --git a/crates/dig-node-service/tests/nightly_release_workflow_shape.rs b/crates/dig-node-service/tests/nightly_release_workflow_shape.rs new file mode 100644 index 0000000..3342510 --- /dev/null +++ b/crates/dig-node-service/tests/nightly_release_workflow_shape.rs @@ -0,0 +1,239 @@ +//! Shape guard for the professional nightlies release system (dig_ecosystem #590/#592). +//! +//! This repo's release orchestrator (`nightly-release.yml`) is copied from the ecosystem's +//! REFERENCE nightlies implementation (DIG-Network/dig-updater) and has a precise, load-bearing +//! shape. These tests pin that shape so a careless edit — or a copy that drifts — cannot silently +//! revert the repo to the old "tag-and-release-on-every-merge" model: +//! +//! 1. The tagger NO LONGER triggers on push-to-main (the whole point of #590 — releases +//! are batched to a nightly cron + manual dispatch instead of firing per merge). +//! 2. It DOES trigger on a midnight-UTC `schedule` cron and on `workflow_dispatch`. +//! 3. The STABLE channel keeps its idempotency keystone: skip cutting `vX.Y.Z` when that +//! tag already exists (an unchanged version = the tag exists = a no-op). +//! 4. The NIGHTLY channel publishes a `prerelease: true` GitHub release under BOTH a dated +//! `nightly-YYYYMMDD` tag and a force-moved rolling `nightly` tag, is never marked +//! `latest`, and prunes old dated nightlies down to a retention window. +//! 5. Both channels preserve the RELEASE_TOKEN posture: no token configured => a clean +//! no-op with a warning, never a half-release. +//! +//! The guard reads the workflow as text (not a YAML parser) on purpose: the invariants are +//! about the literal trigger/step shape a maintainer reads, and a text guard has no external +//! dependency and fails with a message that points at the exact line to fix. + +use std::path::PathBuf; + +/// A workflow file under `.github/workflows/`, resolved relative to this crate. The +/// `dig-node-service` crate sits two levels below the repo root (`crates/dig-node-service`), so +/// the workflows live at `../../.github/workflows/`. +fn workflow(name: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join(".github") + .join("workflows") + .join(name); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +/// The nightly + manual-dispatch release ORCHESTRATOR — the converted on-merge tagger. +fn nightly_release() -> String { + workflow("nightly-release.yml") +} + +/// Extract a workflow's top-level `on:` trigger block: the lines from `on:` (exclusive) up to +/// the next top-level key (a non-indented `word:` such as `jobs:`/`concurrency:`/`permissions:`). +/// Everything nested under `on:` stays; sibling top-level keys are excluded. +fn triggers_block(workflow: &str) -> String { + let mut in_on = false; + let mut lines: Vec<&str> = Vec::new(); + for line in workflow.lines() { + if line.trim_start() == "on:" && !line.starts_with(' ') { + in_on = true; + continue; + } + if in_on { + // A new top-level key (column-0, non-comment, non-blank) ends the `on:` block. + let is_top_level_key = !line.is_empty() + && !line.starts_with(' ') + && !line.starts_with('#') + && line.contains(':'); + if is_top_level_key { + break; + } + lines.push(line); + } + } + lines.join("\n") +} + +#[test] +fn tagger_no_longer_triggers_on_push_to_main() { + let on = triggers_block(&nightly_release()); + assert!( + !on.contains("push:"), + "nightly-release.yml still declares a `push:` trigger — #590 removed push-to-main so \ + releases are cut by the nightly cron + manual dispatch, NOT on every merge. `on:` block:\n{on}" + ); +} + +#[test] +fn tagger_triggers_on_midnight_cron_and_manual_dispatch() { + let on = triggers_block(&nightly_release()); + assert!( + on.contains("schedule:"), + "nightly-release.yml must trigger on a `schedule:` cron. `on:` block:\n{on}" + ); + assert!( + on.contains("0 0 * * *"), + "the nightly cron must be `0 0 * * *` (midnight UTC — GitHub cron is UTC). `on:` block:\n{on}" + ); + assert!( + on.contains("workflow_dispatch:"), + "nightly-release.yml must support `workflow_dispatch:` so a maintainer can cut a release \ + on demand (#590). `on:` block:\n{on}" + ); +} + +#[test] +fn manual_dispatch_offers_channel_and_force_inputs() { + let wf = nightly_release(); + let on = triggers_block(&wf); + assert!( + on.contains("channel:"), + "workflow_dispatch must expose a `channel` input (stable | nightly | both). `on:` block:\n{on}" + ); + assert!( + on.contains("force:"), + "workflow_dispatch must expose a `force` input (re-cut a stable release even if the \ + version is unchanged). `on:` block:\n{on}" + ); +} + +#[test] +fn stable_job_keeps_the_skip_if_already_tagged_guard() { + let wf = nightly_release(); + // The idempotency keystone: an unchanged version means `vX.Y.Z` already exists, so the run + // must skip cutting it. Both the local + remote tag existence check and the skip signal must + // survive the conversion, or the nightly cron would try to re-tag an already-released version. + assert!( + wf.contains("refs/tags/$TAG"), + "the stable job must still check whether the version's `vX.Y.Z` tag already exists" + ); + assert!( + wf.contains("skip=true"), + "the stable job must still short-circuit (skip=true) when the version's tag already exists" + ); +} + +#[test] +fn force_recut_refuses_to_move_a_published_release_onto_a_different_commit() { + let wf = nightly_release(); + // Supply-chain guard (#590 review): `force=true` may re-cut the SAME commit (a failed-build + // retry) or repair a tag with no published release, but must NEVER silently move an existing + // PUBLISHED release's tag onto a DIFFERENT commit — that would overwrite shipped binaries + // with unreviewed code under the same version number. The force branch must (a) resolve the + // existing tag's commit, (b) compare it against the commit this run would build, (c) check + // whether a published (non-draft) GitHub release already sits at that tag, and (d) refuse + // with a non-zero exit when both are true. + assert!( + wf.contains("TAG_COMMIT") && wf.contains("HEAD_COMMIT"), + "the force branch must resolve both the existing tag's commit and this run's target \ + commit so it can compare them before moving the tag" + ); + assert!( + wf.contains("gh release view \"$TAG\"") && wf.contains("isDraft"), + "the force branch must check whether a PUBLISHED (non-draft) release already exists at \ + the tag via `gh release view ... --json isDraft`" + ); + assert!( + wf.contains("IS_PUBLISHED_RELEASE") && wf.contains("TAG_COMMIT\" != \"$HEAD_COMMIT\""), + "the force branch must refuse specifically when the release is published AND the tag's \ + commit differs from the target commit — same-commit re-cuts and no-release repairs \ + must remain allowed" + ); + assert!( + wf.contains("::error::refusing to force-move"), + "the refusal must surface as a `::error::` annotation naming the guard, not a silent skip" + ); +} + +#[test] +fn nightly_job_publishes_a_dated_and_a_rolling_prerelease() { + let wf = nightly_release(); + assert!( + wf.contains("--prerelease"), + "the nightly job must publish a GitHub PRE-release (`--prerelease`), never a stable release" + ); + assert!( + wf.contains("nightly-$DATE") || wf.contains("nightly-${DATE}"), + "the nightly job must publish under a DATED tag `nightly-YYYYMMDD` (built from $DATE)" + ); + assert!( + wf.contains("refs/tags/nightly"), + "the nightly job must force-move a ROLLING `nightly` tag to the newest build" + ); +} + +#[test] +fn nightly_release_is_never_marked_latest() { + let wf = nightly_release(); + assert!( + wf.contains("--latest=false"), + "nightly releases must pass `--latest=false` — only a stable release may move `latest`, \ + so a nightly can never masquerade as the stable download (#590)" + ); + assert!( + !wf.contains("--latest=true"), + "the nightly job must never mark a release `latest`" + ); +} + +#[test] +fn nightly_job_prunes_to_a_retention_window() { + let wf = nightly_release(); + // Retention keeps the newest N dated nightlies (default 14) + the rolling `nightly`, pruning + // older dated releases AND their tags. The count is centralised in a `KEEP_NIGHTLIES` knob. + assert!( + wf.contains("KEEP_NIGHTLIES"), + "the nightly job must define a `KEEP_NIGHTLIES` retention count" + ); + assert!( + wf.contains("--cleanup-tag"), + "pruning must delete BOTH the GitHub release and its git tag (`gh release delete \ + --cleanup-tag`), never orphan a dated `nightly-YYYYMMDD` tag" + ); +} + +#[test] +fn both_channels_no_op_without_release_token() { + let wf = nightly_release(); + assert!( + wf.contains("RELEASE_TOKEN"), + "the release orchestrator must gate on RELEASE_TOKEN" + ); + assert!( + wf.contains("::warning::"), + "a missing RELEASE_TOKEN must degrade to a clear `::warning::` no-op, never a half-release" + ); +} + +/// The reusable build workflow both release paths call MUST exist and be `workflow_call`, or the +/// nightly + stable channels would each hand-roll a divergent build (the DRY invariant of #592). +#[test] +fn reusable_build_workflow_is_workflow_call_and_shared() { + let build = workflow("build-binaries.yml"); + assert!( + build.contains("workflow_call:"), + "build-binaries.yml must be a reusable `on: workflow_call` workflow" + ); + let nightly = nightly_release(); + let release = workflow("release.yml"); + assert!( + nightly.contains("./.github/workflows/build-binaries.yml"), + "the nightly channel must build via the shared build-binaries.yml (never a hand-rolled matrix)" + ); + assert!( + release.contains("./.github/workflows/build-binaries.yml"), + "release.yml (stable) must build via the shared build-binaries.yml (never a hand-rolled matrix)" + ); +} diff --git a/crates/dig-node-service/tests/release_workflow_dign_guard.rs b/crates/dig-node-service/tests/release_workflow_dign_guard.rs index 2a4ce29..899028b 100644 --- a/crates/dig-node-service/tests/release_workflow_dign_guard.rs +++ b/crates/dig-node-service/tests/release_workflow_dign_guard.rs @@ -1,23 +1,25 @@ -//! Guard: the release workflow MUST build + publish the `dign` first-class alias +//! Guard: the reusable build workflow MUST build + publish the `dign` first-class alias //! (issue #548) alongside `dig-node`, so every dig-node GitHub Release carries a //! `dign--[.exe]` asset with the SAME shape as `dig-node--`. //! //! This is the producer-side counterpart to the dig-installer's `Repo::dign()` asset //! matcher (a separate follow-up, #548 step 3): here we assert the workflow actually -//! EMITS the asset the installer will later resolve. `release.yml` is embedded at +//! EMITS the asset the installer will later resolve. The cross-OS build moved out of +//! `release.yml` into the reusable `build-binaries.yml` (#592, so the stable + nightly +//! channels share ONE build); this guard follows it there. The workflow is embedded at //! compile time so the check runs hermetically with no filesystem access. -/// The release workflow, embedded from the repo root (`crates/dig-node-service/tests` +/// The reusable build workflow, embedded from the repo root (`crates/dig-node-service/tests` /// is three levels below it). -const RELEASE_YML: &str = include_str!("../../../.github/workflows/release.yml"); +const BUILD_YML: &str = include_str!("../../../.github/workflows/build-binaries.yml"); /// The build step must compile the `dign` bin target beside `dig-node`; dropping /// `--bin dign` would silently stop shipping the alias. #[test] fn release_workflow_builds_the_dign_bin() { assert!( - RELEASE_YML.contains("--bin dig-node --bin dign"), - "release.yml must `cargo build … --bin dig-node --bin dign`" + BUILD_YML.contains("--bin dig-node --bin dign"), + "build-binaries.yml must `cargo build … --bin dig-node --bin dign`" ); } @@ -26,7 +28,7 @@ fn release_workflow_builds_the_dign_bin() { #[test] fn release_workflow_stages_the_dign_asset() { assert!( - RELEASE_YML.contains("dist/dign-${VER}-${{ matrix.out_name }}"), - "release.yml must stage a `dign--` release asset" + BUILD_YML.contains("dist/dign-${VER}-${{ matrix.out_name }}"), + "build-binaries.yml must stage a `dign--` release asset" ); } diff --git a/runbooks/release.md b/runbooks/release.md new file mode 100644 index 0000000..30c2508 --- /dev/null +++ b/runbooks/release.md @@ -0,0 +1,82 @@ +# Runbook — releasing dig-node (nightly cron + manual dispatch) + +How this repo's `dig-node` binary (+ the `dig-companion` legacy-named copy + the `dign` alias) is +built and released. The shape is copied from the ecosystem's **reference nightlies system** +(`dig-updater`, dig_ecosystem #590/#592); the normative contract is `SPEC.md` §11. + +## TL;DR + +- Releases are **NOT cut on merge to `main`**. They are batched to a **nightly cron at midnight UTC** + plus **manual dispatch**. +- **Stable** (`vX.Y.Z`): cut automatically when the `[workspace.package].version` in the root + `Cargo.toml` was bumped (detected as "the `vX.Y.Z` tag doesn't exist yet"), or on demand. + `prerelease: false`, marked `latest`. Every per-OS/arch binary ships under BOTH `dig-node-*` and + legacy `dig-companion-*` names, plus the `dign-*` alias. +- **Nightly**: built every night from `main` HEAD as a **pre-release** under a dated tag + `nightly-YYYYMMDD` + a rolling `nightly` tag. `prerelease: true`, never `latest`. Keeps 14. + +## Prerequisites / credentials + +- **`RELEASE_TOKEN`** — an org-level classic PAT (the ecosystem release token). Both channels no-op + with a warning if it is absent. Used to push the changelog commit past branch protection and to + push tags that trigger downstream workflows (`GITHUB_TOKEN` cannot do either). + +## If nightlies silently stop — check for the 60-day cron auto-disable + +GitHub disables a `schedule:` trigger after **60 days of no repo activity** on a public repo, with +**no automatic re-enable** — and since this cron is the *only* automatic release trigger, a quiet +repo can go dark with no error. If nightlies (or a long-overdue stable release) stop appearing: + +```bash +gh api repos/DIG-Network/dig-node/actions/workflows/nightly-release.yml --jq .state +# "disabled_inactivity" means GitHub turned it off — re-enable it: +gh workflow enable nightly-release.yml --repo DIG-Network/dig-node +``` + +Any repo activity (a merged PR, a manual dispatch) resets the 60-day counter. + +## Cut a STABLE release (the normal path) + +1. In your feature PR, bump `[workspace.package].version` in the root `Cargo.toml` per SemVer and run + `cargo update --workspace` so `Cargo.lock` matches. Merge the PR (squash). +2. Nothing releases on merge. At the next **midnight UTC** the `nightly-release.yml` cron runs its + **stable** job: it sees the new version has no `vX.Y.Z` tag, regenerates `CHANGELOG.md`, commits + `chore(release): vX.Y.Z` to `main`, tags it, and pushes with `RELEASE_TOKEN`. +3. The pushed `v*` tag fires `release.yml`, which builds every OS/arch and publishes the stable + GitHub Release (dual-named binaries + the `dign` alias, changelog as notes). + +### Cut a stable release NOW / re-cut + +- Now: Actions → **Nightly + stable release** → **Run workflow** → `channel: stable` (or `both`). +- Re-cut (failed build): same, with **`force: true`**. `force` REFUSES (non-zero exit) when the tag + already has a PUBLISHED release AND points at a different commit than this run would build — it + only proceeds for a same-commit retry or a tag with no published release. To ship new code, bump + the version instead. (A force-moved tag breaks tag-immutability; the dig-updater signed feed, not + the tag, is dig-node's integrity anchor — SPEC §11.1.) + +## Cut a NIGHTLY on demand + +Actions → **Nightly + stable release** → **Run workflow** → `channel: nightly` (or `both`) → Run. + +## Verify a release went live + +- **Stable:** `gh release view vX.Y.Z --repo DIG-Network/dig-node` — 4 OS/arch × (`dig-node-*` + + `dig-companion-*` + `dign-*`), `prerelease: false`, marked latest. Watch: `gh run watch `. +- **Nightly:** `gh release view nightly --repo DIG-Network/dig-node` (rolling) or + `gh release view nightly-YYYYMMDD` — `prerelease: true`. + +## Workflows + +| File | Trigger | Role | +|---|---|---| +| `nightly-release.yml` | midnight-UTC cron + `workflow_dispatch` | Orchestrator: stable (changelog + tag) + nightly (build + pre-release + prune). | +| `release.yml` | `push: tags: v*` (+ dispatch canary) | Builds + publishes the stable Release for a `vX.Y.Z` tag. | +| `build-binaries.yml` | `workflow_call` | Reusable cross-OS build, dual-named + `dign` (both channels call it). | +| `ci.yml` | PR + push to main | fmt/clippy + `cargo llvm-cov nextest --workspace` (pre-merge). NOTE: `ubuntu-latest` only — Windows/macOS build breaks are first caught by the nightly channel, not PR CI (SPEC §11 / follow-up). | + +## Local build (dev) + +```bash +cargo build --workspace --release --locked +cargo test --workspace --locked # includes the workflow-shape guard tests +```