From c4a4f1f25cde78ac215bcaa79587834b563cb6c5 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 24 Sep 2026 23:39:38 +0800 Subject: [PATCH] =?UTF-8?q?ci(release):=20=E5=8F=91=E5=B8=83=20SHA256SUMS?= =?UTF-8?q?=E3=80=81SBOM=20=E4=B8=8E=E6=9E=84=E5=BB=BA=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E8=AF=81=E6=98=8E=EF=BC=8Caction=20=E5=9B=BA=E5=AE=9A=E5=88=B0?= =?UTF-8?q?=20commit=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=A6=86=E7=9B=96=E5=B7=B2?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=B5=84=E4=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/dependabot.yml | 8 +++ .github/workflows/ci.yml | 22 +++---- .github/workflows/release.yml | 119 +++++++++++++++++++++++++++++----- 3 files changed, 123 insertions(+), 26 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..03ab875 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +# The workflows pin every action to a commit SHA. This keeps those pins moving: Dependabot +# reads the "# vX.Y.Z" comment next to each SHA and opens a PR that bumps both together. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7401f0..59580bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,9 @@ jobs: go: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod @@ -48,7 +48,7 @@ jobs: shell: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # bootstrap.sh is the only thing that ever runs on a fresh host, and nothing here can # run it — it wants root, a package manager and k3s. Syntax plus the extracted-block @@ -71,7 +71,7 @@ jobs: panel: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # The Dockerfile's `FROM node:` is the only place the panel's Node version is # declared — there is no .nvmrc and no engines field. Reading it here rather than @@ -84,7 +84,7 @@ jobs: [ -n "$version" ] || { echo "Dockerfile has no 'FROM ... node:' line"; exit 1; } echo "version=${version}" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ steps.node.outputs.version }} cache: npm @@ -102,18 +102,18 @@ jobs: plugins: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # The other jobs never touch the Java layer: the plugin jars were only ever # compiled by bootstrap on a live host, and the three test mains under # plugins/*/test were run by hand. JDK 21 plus the Gradle major the plugin # Dockerfiles pin (8.14) is that same toolchain, in CI. - - uses: actions/setup-java@v4 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: temurin java-version: '21' - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 with: gradle-version: '8.14' @@ -122,18 +122,18 @@ jobs: mods: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # The three loader mods (Minecraft 1.20.1 / 1.20.4, Java-17 lines) compile # through their vendored Gradle wrappers, which fetch their own Gradle. Until # this job nothing ever built them: no install path touches them, and their # gradlew scripts were committed without the exec bit, so the README's # one-liners failed on a fresh clone. - - uses: actions/setup-java@v4 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: temurin java-version: '17' - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 - run: bash plugins/test-mods.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43bf311..46bd536 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,16 @@ # quietly ships a release whose panel is that placeholder. The Dockerfile runs the npm # build first, and is the same recipe bootstrap uses, so there is one way to build felis # rather than two that can drift. +# +# SHA256SUMS is a contract with bootstrap too: download_release_binary refuses a binary whose +# hash is not listed there, BEFORE it runs it. A release without the file installs by source +# build instead. +# +# Two jobs, so the write token never meets the test suite: `build` runs the tests, Gradle and +# the Docker build (each of which executes third-party code) with a read-only token and hands +# the binaries over as a workflow artifact; `publish` holds contents:write and runs only +# pinned actions and gh. Every action is pinned to a commit SHA (the tag in the trailing +# comment is for humans); .github/dependabot.yml proposes the bumps. name: release on: @@ -24,15 +34,15 @@ on: tags: ['v*'] permissions: - contents: write # gh release create/upload + contents: read jobs: - release: + build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod @@ -45,23 +55,23 @@ jobs: # compile turns every install of that release into a failed bootstrap. JDK 21 # gates the install-time plugins + codec/invite tests; JDK 17 gates the loader # mods (their vendored wrappers fetch their own Gradle). - - uses: actions/setup-java@v4 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: temurin java-version: '21' - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 with: gradle-version: '8.14' - run: bash plugins/test.sh - - uses: actions/setup-java@v4 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: temurin java-version: '17' - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 - run: bash plugins/test-mods.sh @@ -70,7 +80,7 @@ jobs: # falls back to a slow source build. Neither stage is emulated: the Dockerfile pins # both build stages to $BUILDPLATFORM and the Go stage cross-compiles via TARGETARCH, # so the second architecture costs about a minute. - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build the stamped binaries run: | @@ -100,8 +110,67 @@ jobs: file ./felis-linux-arm64 | grep -q 'ARM aarch64' \ || { echo "felis-linux-arm64 is not an arm64 ELF — TARGETARCH did not reach the go build"; exit 1; } - # --verify-tag refuses to invent a release for a tag that is not pushed. The upload - # fallback makes a re-run converge rather than failing on an existing release. + - name: Checksum the binaries + run: sha256sum felis-linux-amd64 felis-linux-arm64 | tee SHA256SUMS + + # A CycloneDX SBOM per binary: the Go modules (and versions) linked into it, read + # from the build info the linker embeds. + - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + file: felis-linux-amd64 + format: cyclonedx-json + output-file: felis-linux-amd64.cdx.json + upload-artifact: false + upload-release-assets: false + - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + file: felis-linux-arm64 + format: cyclonedx-json + output-file: felis-linux-arm64.cdx.json + upload-artifact: false + upload-release-assets: false + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-assets + path: | + felis-linux-amd64 + felis-linux-arm64 + felis-linux-amd64.cdx.json + felis-linux-arm64.cdx.json + SHA256SUMS + if-no-files-found: error + retention-days: 7 + + publish: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write # gh release create/upload + id-token: write # the Sigstore certificate behind the provenance attestation + attestations: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-assets + + # The artifact store sits between the two jobs, so check the handover too. + - run: sha256sum -c SHA256SUMS + + # Signed SLSA provenance: which workflow run, commit and repository produced each + # binary. Check one with `gh attestation verify felis-linux-amd64 --repo FelisMC/Felis`. + # GitHub only stores attestations for private repositories on Enterprise Cloud, and a + # failure here would block the release, so a private repository skips the step and + # relies on SHA256SUMS alone. + - name: Attest build provenance + if: ${{ !github.event.repository.private }} + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-path: | + felis-linux-amd64 + felis-linux-arm64 + + # --verify-tag refuses to invent a release for a tag that is not pushed. # # The prerelease flag has to be passed explicitly: the trigger glob is v*, so v1.2.3-rc1 # lands here too, and gh does not read semver out of the tag name. Published as a full @@ -109,13 +178,33 @@ jobs: # channel installs from and `felis update` polls — so every fresh install would get the # RC binary and every deployed felis-api would error on the felis component until a # stable tag was cut. Flagged, GitHub keeps latest pointing at the last stable release. + # + # A re-run (the release already exists) uploads only what is missing and never + # replaces a published asset: hosts may already have installed it, and their + # SHA256SUMS check would start failing against a swapped file. An asset that is + # there with different bytes stops the job; cut a new tag instead. - name: Publish the release env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | + assets="felis-linux-amd64 felis-linux-arm64 felis-linux-amd64.cdx.json felis-linux-arm64.cdx.json SHA256SUMS" flags="" case "$GITHUB_REF_NAME" in *-*) flags="--prerelease" ;; esac - gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes $flags \ - ./felis-linux-amd64 ./felis-linux-arm64 \ - || gh release upload "$GITHUB_REF_NAME" \ - ./felis-linux-amd64 ./felis-linux-arm64 --clobber + if ! gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + # shellcheck disable=SC2086 # word-splitting the list is the point + gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes $flags $assets + exit 0 + fi + # The REST payload's per-asset "digest" is GitHub's own sha256 of the stored file. + published="$(gh api "repos/${GH_REPO}/releases/tags/${GITHUB_REF_NAME}" --jq '.assets[] | "\(.name) \(.digest)"')" + for a in $assets; do + have="$(printf '%s\n' "$published" | awk -v n="$a" '$1 == n { print $2 }')" + want="sha256:$(sha256sum < "$a" | cut -d' ' -f1)" + if [ -z "$have" ]; then + gh release upload "$GITHUB_REF_NAME" "$a" + elif [ "$have" != "$want" ]; then + echo "::error::$a is already published with $have; this run built $want. Published assets are never replaced." + exit 1 + fi + done