diff --git a/.github/reproducible-build/Dockerfile.tmpl b/.github/reproducible-build/Dockerfile.tmpl new file mode 100644 index 00000000000..9f8ee8689d4 --- /dev/null +++ b/.github/reproducible-build/Dockerfile.tmpl @@ -0,0 +1,34 @@ +FROM eclipse-temurin:17-jdk@sha256:08295ab0f5007a37cbcc6679a8447a7278d9403f9f82acd80ed08cd10921e026 AS builder + +RUN apt-get update -y && \ + apt-get install -y -qq --no-install-recommends git curl gnupg && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /code/rskj + +RUN gitrev=__REF__ && \ + git init && \ + git remote add origin https://github.com/rsksmart/rskj.git && \ + git fetch --depth 1 origin "$gitrev" && \ + git checkout FETCH_HEAD + +RUN curl -sSL https://secchannel.rsk.co/SUPPORT.asc | gpg --import && \ + gpg --verify --output SHA256SUMS SHA256SUMS.asc && \ + sha256sum --check SHA256SUMS && \ + ./configure.sh && \ + ./gradlew --no-daemon clean build -x test -x checkstyleMain -x checkstyleTest -x checkstyleIntegrationTest && \ + ./gradlew publishRskjPublicationToMavenLocal + + +FROM eclipse-temurin:17-jre@sha256:f1515395c0695910a3ca665e973cc11013d1f50d265e61cb8c9156e999d914b4 AS runner + +RUN useradd -m rsk + +WORKDIR /home/rsk + +USER rsk +COPY --from=builder --chown=rsk:rsk /code/rskj/rskj-core/build/libs/rskj-core-* ./ +COPY --from=builder --chown=rsk:rsk /code/rskj/rskj-core/build/rskj-core-*.pom ./ +COPY --from=builder --chown=rsk:rsk /root/.m2/repository/co/rsk/rskj-core/__VERSION__-__MODIFIER__/*.module ./ + +CMD ["java", "-cp", "rskj-core-__VERSION__-__MODIFIER__-all.jar", "co.rsk.Start"] diff --git a/.github/reproducible-build/README.md.tmpl b/.github/reproducible-build/README.md.tmpl new file mode 100644 index 00000000000..3c698c60b5a --- /dev/null +++ b/.github/reproducible-build/README.md.tmpl @@ -0,0 +1,32 @@ +# rskj __TAG__ + +* Source: https://github.com/rsksmart/rskj +* Tag: `__TAG__` + +## Build + +``` +$ docker build -t rskj/__VERSION__-__MODIFIER_LC__ . +``` + +## Verify + +Run the following command to verify the sha256sum of the built artifacts matches the expected values: + +``` +$ docker run --rm rskj/__VERSION__-__MODIFIER_LC__ sh -c 'sha256sum * | grep -v javadoc.jar' +__HASHES__ +``` + +## (Optional) Run RSK Node +``` +$ docker run -d rskj/__VERSION__-__MODIFIER_LC__ +``` + +## (Optional) Extract JAR from image + +``` +$ cid=$(docker run -d rskj/__VERSION__-__MODIFIER_LC__ /bin/true) +$ docker cp "$cid":/home/rsk/ ./libs/ +$ docker rm "$cid" +``` diff --git a/.github/workflows/reproducible-build-pr.yml b/.github/workflows/reproducible-build-pr.yml new file mode 100644 index 00000000000..73560a6cab5 --- /dev/null +++ b/.github/workflows/reproducible-build-pr.yml @@ -0,0 +1,196 @@ +name: Reproducible build PR + +# Builds the canonical jars via the build-only `reproducible-build.yml`, +# then open a PR against rsksmart/reproducible-builds adding the rskj/- +# directory (Dockerfile + README with the hashes). +# +# Trust model: CI is the *first builder / PR author* only. The hashes it emits +# are informational — merging the generated PR still REQUIRES an independent +# rebuild confirming the same hashes. CI is intentionally not the sole witness. + +on: + # GA release tags look like NAME-1.2.3. + push: + tags: + - "*-[0-9]*.[0-9]*.[0-9]*" + +# Only read permission is needed. The cross-repo PR is opened with the +# scoped App token, NOT GITHUB_TOKEN. +permissions: + contents: read + +concurrency: + group: reproducible-build-pr-${{ github.ref }} + cancel-in-progress: false + +jobs: + # GA-only gate. push.tags globs match pre-release tags too, so re-check the + # tag here and skip (green, not red) anything that isn't a clean GA release. + guard: + runs-on: ubuntu-24.04 + outputs: + is_ga: ${{ steps.check.outputs.is_ga }} + steps: + - name: Classify the pushed tag + id: check + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + is_ga=false + # All-caps release name + three-part numeric version, nothing trailing. + if printf '%s' "$TAG" | grep -Eq '^[A-Z]+-[0-9]+\.[0-9]+\.[0-9]+$'; then + is_ga=true + fi + # Belt-and-suspenders deny-list for pre-release markers (the regex above + # already excludes these; this makes the intent explicit and survives a + # future regex loosening). + case "$TAG" in + *PREVIEW*|*TESTNET*|*SNAPSHOT*|*-rc|*-rc[0-9]*|*-RC*|*-alpha*|*-beta*) + is_ga=false ;; + esac + echo "is_ga=$is_ga" >> "$GITHUB_OUTPUT" + if [ "$is_ga" = "true" ]; then + echo "Tag '$TAG' is a GA release — proceeding." + else + echo "::notice::Tag '$TAG' is not a GA release — skipping reproducible-build PR." + fi + + # Build the canonical jars + hashes. NO `secrets:` passed → the build half can + # never see this workflow's App-token secrets. Runs only for GA tags. + build: + needs: guard + if: needs.guard.outputs.is_ga == 'true' + uses: ./.github/workflows/reproducible-build.yml + with: + ref: ${{ github.ref_name }} + + # The only privileged job: mint the scoped App token and open the PR. + open-pr: + needs: [guard, build] + if: needs.guard.outputs.is_ga == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.build.outputs.version }} + MODIFIER: ${{ needs.build.outputs.modifier }} + TARGET_REPO: rsksmart/reproducible-builds + steps: + - name: Checkout rskj at the tag (templates only) + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.ref_name }} + sparse-checkout: | + .github/reproducible-build + + - name: Download canonical build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.build.outputs.artifact_name }} + path: artifacts + + - name: Render Dockerfile and README for the release directory + id: render + run: | + set -euo pipefail + # modifier_lc derivation lives HERE (the build half is modifier-case + # agnostic); the published dir + README use the lowercase form. + modifier_lc=$(printf '%s' "$MODIFIER" | tr '[:upper:]' '[:lower:]') + echo "modifier_lc=$modifier_lc" >> "$GITHUB_OUTPUT" + + # Hashes come from the artifact's hashes.txt (authoritative copy the + # build job wrote), so README == artifact == a verifier's local check. + if [ ! -s artifacts/hashes.txt ]; then + echo "::error::artifacts/hashes.txt missing or empty"; exit 1 + fi + hashes=$(cat artifacts/hashes.txt) + + mkdir -p out + # Dockerfile pins the immutable tag as the build ref. + sed -e "s|__REF__|${TAG}|g" \ + -e "s|__VERSION__|${VERSION}|g" \ + -e "s|__MODIFIER__|${MODIFIER}|g" \ + .github/reproducible-build/Dockerfile.tmpl > out/Dockerfile + + # README: tag/version/modifier_lc via sed, then the multi-line hash + # block via awk (sed chokes on multi-line / slashed replacements). + sed -e "s|__TAG__|${TAG}|g" \ + -e "s|__VERSION__|${VERSION}|g" \ + -e "s|__MODIFIER_LC__|${modifier_lc}|g" \ + .github/reproducible-build/README.md.tmpl > out/README.partial.md + awk -v hashes="$hashes" '{ gsub(/__HASHES__/, hashes); print }' \ + out/README.partial.md > out/README.md + + - name: Mint scoped GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RSK_CORE_GH_APP_ID }} + private-key: ${{ secrets.RSK_CORE_GH_APP_PRIVATE_KEY }} + owner: rsksmart + repositories: reproducible-builds + + - name: Open the reproducible-builds PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TOKEN: ${{ steps.app-token.outputs.token }} + MODIFIER_LC: ${{ steps.render.outputs.modifier_lc }} + run: | + set -euo pipefail + rel_dir="rskj/${VERSION}-${MODIFIER_LC}" + branch="reproducible-build/${TAG}" + + # Clone over x-access-token (devportal-update.yml pattern). The clone + # lands on the default branch — the reference for the existing-dir check. + git clone "https://x-access-token:${TOKEN}@github.com/${TARGET_REPO}.git" target + cd target + default_branch=$(git rev-parse --abbrev-ref HEAD) + + # Fail-loud: never overwrite an already-published release directory. + if [ -e "$rel_dir" ]; then + echo "::error::${rel_dir} already exists in ${TARGET_REPO} — refusing to overwrite a published release." + exit 1 + fi + + # Dedicated per-tag branch; -B so a re-run resets cleanly off default. + git checkout -B "$branch" + mkdir -p "$rel_dir" + cp ../out/Dockerfile "$rel_dir/Dockerfile" + cp ../out/README.md "$rel_dir/README.md" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "$rel_dir" + git commit -m "Add reproducible build for ${TAG}" + # Force-push: the branch is ours alone (per-tag), so a re-run overwrites + # its own prior push rather than failing. + git push -u -f origin "$branch" + + # gh (unlike peter-evans) won't reconcile an existing PR, so check first. + existing=$(gh pr list --repo "$TARGET_REPO" --head "$branch" --state open \ + --json url -q '.[0].url' || true) + if [ -n "$existing" ]; then + echo "::notice::PR already open for ${branch}: ${existing}" + exit 0 + fi + + body=$(cat < build that commit, publish canonical hashes +# - workflow_dispatch (ref input) -> build an arbitrary ref on demand +# - workflow_call (ref input) -> building block for the release workflow +# (.github/workflows/reproducible-build-pr.yml), +# which opens the reproducible-builds PR +# +# What it does: checks out the ref for its templates + version.properties, renders +# the pinned hermetic Dockerfile, builds it (the Dockerfile git-fetches the ref +# itself from canonical upstream — the runner checkout is only for the templates), +# extracts the jars, hashes them, and uploads jars + hashes.txt as a run artifact. +# +# Trust model: this workflow is the *first builder* only. The hashes it emits are +# informational — NOT an authoritative attestation. A release must still be +# independently rebuilt to confirm the same hashes; CI is not the sole witness. +# On a non-tag push there is moreover no published release to compare against: +# the run is a hermetic build + canonical-hash record, not a reproducibility check. +# +# This workflow holds no secrets and only `contents: read`. All privileged, +# cross-repo work (App token, opening the reproducible-builds PR) lives solely in +# .github/workflows/reproducible-build-pr.yml. + +on: + push: + branches: ["master", "*-rc"] + workflow_dispatch: + inputs: + ref: + description: 'git ref to build (branch / tag / SHA; default: this commit)' + required: false + type: string + workflow_call: + inputs: + ref: + description: 'git ref to build (branch / tag / SHA)' + required: true + type: string + outputs: + hashes: + description: 'sha256sum block of the canonical jars (informational)' + value: ${{ jobs.reproducible-build.outputs.hashes }} + artifact_name: + description: 'name of the uploaded run artifact (jars + hashes.txt)' + value: ${{ jobs.reproducible-build.outputs.artifact_name }} + version: + description: 'versionNumber resolved from version.properties at the ref' + value: ${{ jobs.reproducible-build.outputs.version }} + modifier: + description: 'modifier resolved from version.properties at the ref' + value: ${{ jobs.reproducible-build.outputs.modifier }} + +permissions: + contents: read + +concurrency: + # Key by the ref actually being built (inputs.ref for dispatch/call, the commit + # for push) so builds of different refs never share a group. Never cancel an + # in-flight build: same-ref re-triggers queue instead, and a release-tag build + # driven via workflow_call is never aborted out from under the PR workflow. + group: reproducible-build-${{ inputs.ref || github.sha }} + cancel-in-progress: false + +jobs: + reproducible-build: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + outputs: + hashes: ${{ steps.hashes.outputs.hashes }} + artifact_name: ${{ steps.meta.outputs.artifact_name }} + version: ${{ steps.meta.outputs.version }} + modifier: ${{ steps.meta.outputs.modifier }} + steps: + - name: Checkout the ref (templates + version.properties live here) + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.ref || github.sha }} + # The job only reads the templates and version.properties; skip the + # rest of the tree. + sparse-checkout: | + .github/reproducible-build + rskj-core/src/main/resources + + - name: Resolve ref, version and modifier + id: meta + env: + REF: ${{ inputs.ref || github.sha }} + run: | + set -euo pipefail + # Validate REF before it flows into a sed `s|__REF__|...|` delimiter and + # the Dockerfile's git refspec. Same gate style as + # .github/workflows/rskj-core-automated-tests.yml. + case "$REF" in + ""|*..*) echo "::error::invalid ref (empty or contains '..'): '$REF'"; exit 1;; + *[!A-Za-z0-9._/-]*) echo "::error::ref has disallowed characters: '$REF'"; exit 1;; + esac + + # Read from the checked-out tree at REF (the Dockerfile's runner stage + # needs version+modifier for the *.module COPY and the -all.jar CMD). + props=rskj-core/src/main/resources/version.properties + version=$(sed -n "s/^versionNumber=['\"]\(.*\)['\"].*/\1/p" "$props") + modifier=$(sed -n "s/^modifier=['\"]\(.*\)['\"].*/\1/p" "$props") + if [ -z "$version" ] || [ -z "$modifier" ]; then + echo "::error::could not parse version.properties at ${REF}"; exit 1 + fi + { + echo "version=$version" + echo "modifier=$modifier" + echo "artifact_name=rskj-repro-artifacts" + } >> "$GITHUB_OUTPUT" + echo "Resolved ${REF} -> version=$version modifier=$modifier" + + - name: Render Dockerfile from template + env: + REF: ${{ inputs.ref || github.sha }} + VERSION: ${{ steps.meta.outputs.version }} + MODIFIER: ${{ steps.meta.outputs.modifier }} + run: | + set -euo pipefail + mkdir -p out + sed -e "s|__REF__|${REF}|g" \ + -e "s|__VERSION__|${VERSION}|g" \ + -e "s|__MODIFIER__|${MODIFIER}|g" \ + .github/reproducible-build/Dockerfile.tmpl > out/Dockerfile + + - name: Build the reproducible image + run: | + set -euo pipefail + # The Dockerfile is fully hermetic (it git-fetches the ref itself), so + # the build context is irrelevant — send an empty one to keep it fast. + # --no-cache guarantees nothing is reused from a previous run. + mkdir -p ctx + docker build --no-cache -t rskj-repro:build -f out/Dockerfile ctx + + - name: Extract built artifacts + run: | + set -euo pipefail + # The artifacts live inside the image (runner stage WORKDIR /home/rsk). + # Use a created (not run) container so the node ENTRYPOINT never starts. + cid=$(docker create rskj-repro:build) + mkdir -p artifacts + docker cp "$cid":/home/rsk/. artifacts/ + docker rm "$cid" >/dev/null + ls -l artifacts + + - name: Compute artifact hashes + id: hashes + run: | + set -euo pipefail + # Same command a verifier runs locally, so the artifact's hashes.txt and + # any downstream README match byte for byte. + hashes=$(docker run --rm rskj-repro:build sh -c 'sha256sum * | grep -v javadoc.jar') + printf '%s\n' "$hashes" + # Ship the hashes alongside the jars so the release workflow (and a human) + # has an authoritative copy in the artifact. + printf '%s\n' "$hashes" > artifacts/hashes.txt + { + echo "hashes<> "$GITHUB_OUTPUT" + + - name: Write job summary + env: + REF: ${{ inputs.ref || github.sha }} + VERSION: ${{ steps.meta.outputs.version }} + MODIFIER: ${{ steps.meta.outputs.modifier }} + HASHES: ${{ steps.hashes.outputs.hashes }} + run: | + set -euo pipefail + { + echo "## Reproducible build — \`${REF}\`" + echo "" + echo "Resolved version: \`${VERSION}-${MODIFIER}\`" + echo "Jars + \`hashes.txt\` attached to this run as artifact: \`rskj-repro-artifacts\`" + echo "" + echo "> Hashes are informational (first-builder only), not an attestation." + echo "" + echo "### Artifact hashes" + echo '```' + printf '%s\n' "$HASHES" + echo '```' + echo "" + echo "
Generated Dockerfile" + echo "" + echo '```dockerfile' + cat out/Dockerfile + echo '```' + echo "
" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: rskj-repro-artifacts + path: artifacts/ + if-no-files-found: error