diff --git a/.github/actions/semver-validation/README.md b/.github/actions/semver-validation/README.md index 348ef2c3..19254236 100644 --- a/.github/actions/semver-validation/README.md +++ b/.github/actions/semver-validation/README.md @@ -4,10 +4,11 @@ Reports on a version string: whether it is valid [semver](https://semver.org/), what its parts are, which release channel it belongs to, and how it orders against another version. -The work is done by [`semstat`](https://github.com/loft-sh/semstat), which the -action downloads and checksum-verifies at the pinned release. One implementation -answers for the action and for the shell scripts in this repository, so there is -no second semver engine to disagree with the first. +The work is done by [`semstat`](https://github.com/loft-sh/semstat), installed and +checksum-verified by +[`setup-semstat`](../setup-semstat/README.md), which is where the release pin +lives. One implementation answers for the action and for the shell scripts in this +repository, so there is no second semver engine to disagree with the first. ## Runner requirements @@ -17,21 +18,35 @@ The action needs a Linux or macOS runner with `curl`, `tar`, `jq` and either `objects.githubusercontent.com`, which release-asset downloads redirect to. A proxy allowlist that names only `github.com` fails the install step. -That is new in `semver-validation/v4`. The tags before it (`v1`, `v2` and `v3`) all -point at the self-contained Node action, which needed neither the network nor those -tools. A caller on a runner without egress keeps working on those tags and fails the -install step on `v4`. `v1` through `v3` stay where they are for that reason; none of -them were advanced onto this rewrite. +Because `verify-signature` defaults to true, two further hosts are needed by +default: a `cosign` download from the `github.com/sigstore/cosign` releases, and +egress to `tuf-repo-cdn.sigstore.dev`, where cosign fetches the trusted root it +checks the transparency log against. `verify-signature: false` drops both, and is +the setting for a runner whose allowlist cannot be changed. + +Calling it leaves the caller's `PATH` alone. The installer it shares with +[`setup-semstat`](../setup-semstat/README.md) can put semstat there, and does for +callers that run it as a bare command, but this action names the binary by absolute +path and asks the installer to skip the append, so a job with its own semstat on +`PATH` keeps resolving to that one. + +All of that is new, so the rewrite ships as `semver-validation/v4`. The tags before +it (`v1`, `v2` and `v3`) all point at the self-contained Node action, which needed +neither the network nor those tools. A caller on a runner without egress keeps +working on those tags and fails the install step on `v4`, so all three stay where +they are and none is advanced onto this rewrite. Live +callers pin `v1` and `v3` as floating tags, so advancing either would hand them the +network and tool requirements with no version change to notice. ## Inputs -| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | -|-----------------|--------|----------|------------|--------------------------------------------------------------------------------------------| -| compare_to | string | false | | Second version to order `version` against.
Leave empty to skip the comparison. | -| semstat_version | string | false | `"v0.0.2"` | Release of [loft-sh/semstat](https://github.com/loft-sh/semstat) to download and
run. | -| version | string | true | | Version string to validate against semver
format | +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|------------------|--------|----------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| compare_to | string | false | | Second version to order `version` against.
Leave empty to skip the comparison. | +| verify-signature | string | false | `"true"` | Verify the semstat release's `checksums.txt` against
its cosign bundle before trusting it,
proving the release came from semstat's
own release workflow at that exact
tag rather than only that the
download arrived intact. `checksums.txt` is fetched
from the same release as the
archive, so on its own it
proves the download arrived whole and
nothing about who published it. On
by default, because this action's `release_type`
and `is_stable` route publishes: a semstat
that misreports a prerelease as stable
sends an rc out as the
newest release. Set it to false
to trade that for a cosign
install on the job and a
dependency on Sigstore being reachable. | +| version | string | true | | Version string to validate against semver
format | @@ -80,8 +95,8 @@ them were advanced onto this rewrite. An invalid version is an answer, not a failure: the step stays green and sets `is_valid` to `false`, so the caller decides what that means. The step fails only -when it cannot answer at all: an empty `version`, a `semstat_version` with no -release, or a semstat that did not run. `version` and `compare_to` are trimmed +when it cannot answer at all: an empty `version`, a semstat release that could not +be installed, or a semstat that did not run. `version` and `compare_to` are trimmed before they are read, so surrounding whitespace does not change the answer, and a `version` that is only whitespace is an invalid version rather than a missing one. A binary that crashed must not report `is_valid=false` for a perfectly good tag, @@ -140,12 +155,37 @@ never affects it, and a prerelease always sorts below its final release, so `compare_to` is omitted or is not a version, which is not the same as `false`, so gate on `is_greater == 'true'`. +### Verify who produced the semstat release + +```yaml +- name: Is this tag newer than what is released? + id: semver + uses: loft-sh/github-actions/.github/actions/semver-validation@semver-validation/v4 + with: + version: ${{ github.ref_name }} + compare_to: ${{ steps.latest.outputs.tag }} + verify-signature: true +``` + +The release `checksums.txt` proves the download arrived intact and resolved inside +the release asked for, but it comes from the same place as the archive, so it says +nothing about who published either. `verify-signature` installs `cosign` and checks +`checksums.txt` against its Sigstore bundle at the exact signing identity before +reading it; [`setup-semstat`](../setup-semstat/README.md) documents the identity +and the cost. + +It is on by default here, and the example above names it only to be explicit. +`release_type` and `is_stable` are what route a publish, so a semstat that +misreports a prerelease as stable is enough to send an rc out as the newest +release. Set it to false for a job that only reports a version to a human, which +buys back the cosign install and the dependency on Sigstore being reachable. + ## From a shell script The action is a workflow step, so it cannot be called from inside a loop or a -shell function. Install -[`semstat`](https://github.com/loft-sh/semstat#install) and call it directly -instead: +shell function. Put semstat on `PATH` with +[`setup-semstat`](../setup-semstat/README.md) — or, outside a workflow, [install +it](https://github.com/loft-sh/semstat#install) — and call it directly instead: ```bash newest="" @@ -174,24 +214,30 @@ fi ## Upgrading the semstat release -`semstat_version` pins which release is downloaded, and Renovate opens the bump. -The action verifies the archive against the release checksums and refuses a -binary that reports a different version than the one asked for, so a mismatched -or truncated download fails the step rather than answering wrongly. That is a -transfer check, not a signature check, and deliberately so: semstat is ours, so -the release is trusted and the sigstore bundle it also publishes is left alone. +There is no `semstat_version` input: the pin lives in +[`setup-semstat`](../setup-semstat/README.md), so Renovate opens one bump there +rather than one per action that runs semstat. `setup-semstat` verifies the archive +against the release checksums and refuses a binary that reports a different version +than the one asked for, so a mismatched or truncated download fails the step rather +than answering wrongly. + +The installer runs out of the same checkout as this action rather than through a +`uses:` pin, so a Renovate bump of the release and a fix to the installer both +reach this action in the commit that makes them. See +[`setup-semstat`](../setup-semstat/README.md) for why a sibling in the same +repository cannot be pinned by SHA without stranding it. ## Development ```bash -make test-semver-validation # bats suites for both scripts +make test-semver-validation # bats suite for report.sh make lint # actionlint + zizmor make generate-docs # refresh the tables above from action.yml ``` -`src/install-semstat.sh` downloads and verifies the binary; `src/report.sh` runs -it and writes the outputs. semstat is stubbed in the tests, because what its -answers should be is settled by [its own +`src/report.sh` runs semstat and writes the outputs; installing it is +`setup-semstat`'s job and is tested there. semstat itself is stubbed in these +tests, because what its answers should be is settled by [its own suite](https://github.com/loft-sh/semstat); what is tested here is the translation into action outputs. `test-semver-validation.yaml` also runs the action end to end against the real release. diff --git a/.github/actions/semver-validation/action.yml b/.github/actions/semver-validation/action.yml index 27c2ce94..291d1f84 100644 --- a/.github/actions/semver-validation/action.yml +++ b/.github/actions/semver-validation/action.yml @@ -13,11 +13,20 @@ inputs: description: "Second version to order `version` against. Leave empty to skip the comparison." required: false default: "" - semstat_version: - description: "Release of [loft-sh/semstat](https://github.com/loft-sh/semstat) to download and run." + verify-signature: + description: | + Verify the semstat release's `checksums.txt` against its cosign bundle before + trusting it, proving the release came from semstat's own release workflow at + that exact tag rather than only that the download arrived intact. `checksums.txt` + is fetched from the same release as the archive, so on its own it proves the + download arrived whole and nothing about who published it. + + On by default, because this action's `release_type` and `is_stable` route + publishes: a semstat that misreports a prerelease as stable sends an rc out as + the newest release. Set it to false to trade that for a cosign install on the + job and a dependency on Sigstore being reachable. required: false - # renovate: datasource=github-releases depName=loft-sh/semstat - default: v0.0.2 + default: "true" outputs: is_valid: @@ -60,19 +69,104 @@ outputs: runs: using: "composite" steps: + # jq belongs to the reporting step below, but a caller cannot run one step of + # this composite without the other, and saying so before the downloads is + # what keeps a runner missing jq from paying for tools it cannot use. + # report.sh checks again, for the runs that reach it directly. + # + # Held back when `version:` is absent, so report.sh stays the one that + # answers that case: it checks the missing input first, deliberately above + # its own jq check, and emits the outputs alongside the error. Failing here + # instead would swap that report for a jq message and leave the outputs + # empty. The two checks have to keep agreeing about which comes first. + - name: Check the runner can read semstat's output + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ -n "${INPUT_VERSION-}" ] && ! command -v jq >/dev/null 2>&1; then + echo "::error::jq is required to read semstat's output and is not on PATH" + exit 1 + fi + + # cosign-installer re-downloads its bootstrap binary and re-appends to + # GITHUB_PATH on every call, so a job that reports on several versions pays + # for it once per call while every semstat install after the first is a cache + # hit that never reaches cosign. The marker lives under RUNNER_TEMP and so is + # private to this job, which is what makes this skip an install this job + # already did rather than adopt whatever cosign a runner image shipped. + # + # Recorded before the install rather than after: a cosign-installer that + # fails takes the job with it, so nothing reads the marker afterwards, and a + # caller that swallows that failure meets the installer's own "needs cosign + # on PATH" check on the next call rather than a silent unverified install. + # + # This gate is duplicated in setup-semstat/action.yml, because a composite + # cannot share steps with a sibling. The two copies coordinate through the + # marker path below, so a job mixing both actions only installs cosign once: + # change the path or the cosign-installer pin in one and the pair silently + # stops agreeing. Edit them together. + - name: Check whether this job still needs cosign + id: cosign + if: inputs.verify-signature == 'true' + shell: bash + env: + COSIGN_MARKER: ${{ runner.temp }}/.setup-semstat-cosign-installed + run: | + install=true + if [ -f "$COSIGN_MARKER" ]; then + install=false + elif ! : >"$COSIGN_MARKER"; then + echo "::error::could not record that this job installs cosign at ${COSIGN_MARKER}" + exit 1 + fi + if ! echo "install=${install}" >>"$GITHUB_OUTPUT"; then + echo "::error::could not write whether this job needs cosign to GITHUB_OUTPUT" + exit 1 + fi + + - name: Install cosign + if: inputs.verify-signature == 'true' && steps.cosign.outputs.install == 'true' + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + # setup-semstat's installer, run out of the checkout this action came in + # rather than through `uses:`. Neither form of `uses:` works for a sibling in + # the same repository: a relative `uses: ./...` resolves against the caller's + # workspace and finds nothing once this action is consumed from another repo, + # and a SHA pin can only name a commit from before the change that needs it, + # which then keeps running while fixes to the installer land elsewhere. Both + # actions ship from one commit, so running the script off github.action_path + # is what makes them impossible to drift apart. Which release is installed, + # and the Renovate comment tracking it, live with the script. - name: Install semstat id: install shell: bash env: - SEMSTAT_VERSION: ${{ inputs.semstat_version }} - # Composite steps inherit the job's environment, and the installer takes - # a file:// download root for its own tests. Cleared here, where step env - # wins, so a workflow-level env: or an earlier step writing GITHUB_ENV - # cannot repoint which executable this action runs: the archive and the - # checksums.txt it is verified against come from the same root, so a - # planted release verifies against its own planted manifest. + SEMSTAT_VERIFY_SIGNATURE: ${{ inputs.verify-signature }} + # Composite steps inherit the job's environment, and the installer reads + # both of these from it. Cleared here, where step env wins, so a + # workflow-level env: or an earlier step writing GITHUB_ENV cannot + # repoint which executable this action runs: the base URL because the + # archive and the checksums.txt it is verified against come from the same + # root, so a planted release verifies against its own planted manifest, + # and the version because this action ships one pinned release rather + # than whichever one the job asks for. + SEMSTAT_VERSION: "" SEMSTAT_BASE_URL: "" - run: ${{ github.action_path }}/src/install-semstat.sh + # The reporting step names the binary through SEMSTAT_BIN and never runs + # a bare `semstat`, so the installer's PATH append would be a change to + # the caller's job with nothing here reading it. + SEMSTAT_SKIP_PATH: "true" + INSTALLER: ${{ github.action_path }}/../setup-semstat/src/install-semstat.sh + run: | + # A checkout that sparsely took only this action's directory leaves the + # sibling installer absent, and bash's own "No such file or directory" + # names neither what is missing nor what the checkout has to include. + if [ ! -x "$INSTALLER" ]; then + echo "::error::${INSTALLER} is missing or not executable; this action runs the installer that ships with the sibling setup-semstat action, so a sparse checkout has to take .github/actions/setup-semstat alongside .github/actions/semver-validation" + exit 1 + fi + "$INSTALLER" - name: Report on the version id: report diff --git a/.github/actions/setup-semstat/README.md b/.github/actions/setup-semstat/README.md new file mode 100644 index 00000000..a35d9e0d --- /dev/null +++ b/.github/actions/setup-semstat/README.md @@ -0,0 +1,274 @@ +# Setup semstat Action + +Installs [`semstat`](https://github.com/loft-sh/semstat), the semver CLI this +repository's release actions answer semver questions with, from a pinned release. +Resolves the runner's os and arch, downloads the archive and the release +`checksums.txt`, verifies the one line that covers the archive, unpacks it, and +proves the result runs by comparing `semstat version` against the tag that was +asked for. + +The binary lands on `PATH`, so the actions that call semstat from inside a shell +function or a `while read` loop can just run `semstat`. The absolute path is an +output as well, for a caller that would rather name the binary than depend on +`PATH` ordering. + +One pin lives here rather than one per action, so there is one place to bump and +one release under review rather than five that can drift apart. An action in this +repository runs the installer out of the same checkout and so is never behind it; +a consumer in another repository pins this action by SHA and gets a bump when that +pin moves. + +## Runner requirements + +A Linux or macOS runner with `curl`, `tar` and `sha256sum` (or `shasum`) on it, +and egress to `github.com/loft-sh/semstat/releases/download` **and** +`objects.githubusercontent.com`, which release-asset downloads redirect to. A +proxy allowlist naming only `github.com` fails the download. + +Two further hosts are needed **by default**, because `verify-signature` defaults to +true. `cosign` is downloaded from the `github.com/sigstore/cosign` releases, by this +action rather than by the runner image, and cosign then checks the transparency log +against a trusted root it fetches from `tuf-repo-cdn.sigstore.dev`. An +egress-restricted runner has to allow both, or the step fails inside cosign. + +`verify-signature: false` drops both, and is the setting for a runner whose +allowlist cannot be changed. + +## Inputs + + + +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|------------------|--------|----------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| verify-signature | string | false | `"true"` | Verify `checksums.txt` against its cosign bundle
before trusting it, proving the release
came from semstat's own release workflow
at this exact tag rather than
only that the download arrived intact.
`checksums.txt` is fetched from the same
release as the archive, so on
its own it proves the download
arrived whole and nothing about who
published it; this is the check
that answers that. On by default.
Set it to false to trade
the guarantee for a cosign install
on the job and a dependency
on Sigstore being reachable, which is
worth doing for a job that
neither publishes nor gates a publish. | +| version | string | false | | Release of [loft-sh/semstat](https://github.com/loft-sh/semstat) to install. Empty
installs the release pinned in `src/install-semstat.sh`,
which is where Renovate bumps it
and where every entry point reads
it from. | + + + +## Outputs + + + +| OUTPUT | TYPE | DESCRIPTION | +|--------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| +| path | string | Absolute path to the verified semstat
binary. The directory holding it is
also on PATH for later steps,
so a caller can just run
`semstat`. | + + + +## Usage + +### From a workflow, or from an action in another repository + +```yaml +- name: Install semstat + id: semstat + uses: loft-sh/github-actions/.github/actions/setup-semstat@ # setup-semstat/v1 + +- name: Pick the newest tag + shell: bash + run: | + newest="" + while read -r tag; do + semstat validate "$tag" 2>/dev/null || continue + if [ -z "$newest" ] || semstat gt "$tag" "$newest"; then + newest="$tag" + fi + done < <(git tag -l 'v*') + echo "$newest" +``` + +Pin the full commit SHA with the tag in a trailing comment, the way `cve-scan` +and `govulncheck` reference `ci-test-notify`. A fix to the installer reaches such +a consumer only when its SHA is bumped, so releasing this action means advancing +`setup-semstat/v1` and then moving each cross-repo pin onto the new commit. + +### From another action in this repository + +Run the script instead of pinning the action. Copy this whole shape rather than +the one-line `run:` it reduces to: the `env:` block and the existence check are +both load-bearing, for the reasons below. + +```yaml +- name: Check whether this job still needs cosign + id: cosign + if: inputs.verify-signature == 'true' + shell: bash + env: + COSIGN_MARKER: ${{ runner.temp }}/.setup-semstat-cosign-installed + run: | + install=true + if [ -f "$COSIGN_MARKER" ]; then + install=false + elif ! : >"$COSIGN_MARKER"; then + echo "::error::could not record that this job installs cosign at ${COSIGN_MARKER}" + exit 1 + fi + if ! echo "install=${install}" >>"$GITHUB_OUTPUT"; then + echo "::error::could not write whether this job needs cosign to GITHUB_OUTPUT" + exit 1 + fi + +- name: Install cosign + if: inputs.verify-signature == 'true' && steps.cosign.outputs.install == 'true' + uses: sigstore/cosign-installer@ # v4.1.2 + +- name: Install semstat + id: install + shell: bash + env: + SEMSTAT_VERIFY_SIGNATURE: ${{ inputs.verify-signature }} + SEMSTAT_VERSION: "" + SEMSTAT_BASE_URL: "" + SEMSTAT_SKIP_PATH: "" + INSTALLER: ${{ github.action_path }}/../setup-semstat/src/install-semstat.sh + run: | + if [ ! -x "$INSTALLER" ]; then + echo "::error::${INSTALLER} is missing or not executable; this action runs the installer that ships with the sibling setup-semstat action, so a sparse checkout has to take .github/actions/setup-semstat as well" + exit 1 + fi + "$INSTALLER" +``` + +Neither form of `uses:` works for a sibling in the same repository. A relative +`uses: ./...` resolves against the caller's workspace rather than against this +repository, so it finds nothing once the action is consumed from `vcluster-pro` or +`loft-enterprise`. A full `loft-sh/github-actions/...@` pin does resolve, but +a sibling can only be pinned at a commit that predates the change needing it, and +it then keeps running that commit while fixes to the installer land beside it — +the pin drift from DEVOPS-1126 and DEVOPS-923, silent because the action still +works. `github.action_path` is inside a full checkout of this repository, so the +two ship from one commit and cannot drift apart. `semver-validation` is the +worked example. + +Bind every variable the script reads in that same `env:`, including the ones the +calling action does not expose: a composite step inherits the job's environment +and step env is what wins, so a name left unbound lets a workflow-level `env:` or +an earlier `GITHUB_ENV` write repoint the download root, the release that gets +installed, or whether semstat lands on `PATH` at all. All four are optional to the +script and every one of them is worth binding. + +Check the script is there before running it. `github.action_path` reaches outside +the calling action's own directory, and this repository's workflows do sparse +single-action checkouts, so a caller that took only its own action directory +otherwise dies on bash's bare `No such file or directory` with no `::error::` line +naming what the checkout has to include. + +Verifying also needs `cosign` on `PATH`, which is a `uses:` step and so cannot be +lent out by a script. That is why the marker step above sits in front of it: +cosign-installer re-downloads its bootstrap binary on every call, while every +semstat install after the first is a cache hit that never reaches cosign, so an +action invoked several times in one job would pay for a bootstrap it does not use. +The marker lives under `RUNNER_TEMP`, private to the job, which is what makes the +skip reuse an install this job already did rather than adopt whatever `cosign` a +runner image happened to ship. Keep the marker path byte-identical to the one in +`setup-semstat/action.yml` and `semver-validation/action.yml`, since that shared +path is how a job mixing these actions installs cosign only once. Drop the step +only if the calling action can run at most once per job. + +Set `SEMSTAT_SKIP_PATH: "true"` where the calling action names the binary through +the step output and never runs a bare `semstat`, because the append is then a +change to the caller's job with nothing reading it. `semver-validation` does +exactly this. + +The step output is `semstat` rather than this action's `path` output, because the +script writes it directly: `${{ steps.install.outputs.semstat }}`. + +### Naming the binary instead of relying on PATH + +```yaml +- name: Install semstat + id: semstat + uses: loft-sh/github-actions/.github/actions/setup-semstat@ # setup-semstat/v1 + +- name: Order two versions + shell: bash + env: + SEMSTAT_BIN: ${{ steps.semstat.outputs.path }} + run: "$SEMSTAT_BIN" gt "$CANDIDATE" "$CURRENT" +``` + +### Verifying who produced the release + +```yaml +- name: Install semstat + uses: loft-sh/github-actions/.github/actions/setup-semstat@ # setup-semstat/v1 + with: + verify-signature: true +``` + +`checksums.txt` on its own proves the download arrived intact and that the asset +name resolved inside the release that was asked for. It does not prove who +produced that release, because the manifest comes from the same place as the +archive. With `verify-signature: true` the action installs `cosign` and verifies +`checksums.txt` against its Sigstore bundle before reading it, at the exact +identity: + +``` +https://github.com/loft-sh/semstat/.github/workflows/release.yaml@refs/tags/ +``` + +The tag is part of the identity, not a wildcard, so a bundle signed by the same +workflow on any other ref does not pass. + +It is on by default. The reasoning that first made it opt-in, that verifying a +first-party binary out of a first-party release is ceremony, does not survive what +the release actually looks like: `checksums.txt` is fetched from the same release +as the archive it vouches for, so anyone able to replace one replaces the other in +the same call, and a release only stops being replaceable once it is cut under +GitHub's release immutability. Releases published before that are mutable forever. +The signature is the only part of the chain that does not come from the thing it +is checking. + +Set it to false where the answer feeds nothing that publishes. That buys back a +cosign install on the job, and removes a dependency on Sigstore being reachable, +which is the real cost: with it on, a Sigstore outage fails the job rather than +degrading it. + +## Reusing an install across steps + +The unpack directory is keyed by release and platform under `RUNNER_TEMP`, so a +job that installs semstat in several steps downloads the release once. A cached +install is reused only if the binary still runs, still agrees about its version, +and was verified as strictly as the step asks: a step with +`verify-signature: true` re-installs over whatever an earlier unverified step +left behind rather than trusting it. + +The runner *prepends* `GITHUB_PATH` entries, so where two steps install different +semstat releases into the same job, a later bare `semstat` resolves to whichever +installed last. Anything that cares which release it is talking to should name the +`path` output instead of relying on that order; an in-repository caller that only +ever names the output can set `SEMSTAT_SKIP_PATH` and leave the job's `PATH` out +of it altogether. + +## Upgrading the pinned release + +`DEFAULT_VERSION` in `src/install-semstat.sh` is the pin, carrying the +`# renovate: datasource=github-releases depName=loft-sh/semstat` comment that a +`customManager` in `renovate.json` reads. It lives in the script rather than in +`action.yml` because both entry points reach the script, and the `version` input +only overrides it; two defaults would be two pins that can disagree. + +Renovate opens one bump, and no consumer carries a pin of its own. An action in +this repository gets it in the same commit. A cross-repo consumer pins this action +by SHA, so for it the bump lands here first and ships when its pin moves — two +steps, the same way any change to this action ships. + +The action refuses a binary that reports a different version than the one asked +for, so a mismatched or truncated download fails the step rather than answering +wrongly. + +## Development + +```bash +make test-setup-semstat # bats suite +make lint # actionlint + zizmor +make generate-docs # refresh the tables above from action.yml +``` + +The suite serves a release from disk through a stubbed `curl` and a stubbed +`cosign`, so the download, the checksum verification, the signature check and the +version cross-check all run for real against artifacts the test builds. +`SEMSTAT_BASE_URL` is what repoints the download root, and it takes `file://` +URLs only: a remote value is refused rather than ignored, because a composite +step inherits the job's environment and a workflow-level `env:` would otherwise +repoint both the binary and the manifest it is checked against. diff --git a/.github/actions/setup-semstat/action.yml b/.github/actions/setup-semstat/action.yml new file mode 100644 index 00000000..105c9ce5 --- /dev/null +++ b/.github/actions/setup-semstat/action.yml @@ -0,0 +1,94 @@ +name: "Setup semstat" +description: "Installs a pinned release of the semstat semver CLI, verifies it, and puts it on PATH." +author: "vCluster Labs" +branding: + icon: "download" + color: "green" + +inputs: + version: + description: | + Release of [loft-sh/semstat](https://github.com/loft-sh/semstat) to install. + Empty installs the release pinned in `src/install-semstat.sh`, which is + where Renovate bumps it and where every entry point reads it from. + required: false + default: "" + verify-signature: + description: | + Verify `checksums.txt` against its cosign bundle before trusting it, proving the + release came from semstat's own release workflow at this exact tag rather than + only that the download arrived intact. `checksums.txt` is fetched from the same + release as the archive, so on its own it proves the download arrived whole and + nothing about who published it; this is the check that answers that. + + On by default. Set it to false to trade the guarantee for a cosign install on + the job and a dependency on Sigstore being reachable, which is worth doing for a + job that neither publishes nor gates a publish. + required: false + default: "true" + +outputs: + path: + description: "Absolute path to the verified semstat binary. The directory holding it is also on PATH for later steps, so a caller can just run `semstat`." + value: ${{ steps.install.outputs.semstat }} + +runs: + using: "composite" + steps: + # cosign-installer re-downloads its bootstrap binary and re-appends to + # GITHUB_PATH on every call, so a job that installs semstat in several steps + # pays for it once per step while every install after the first is a cache + # hit that never reaches cosign. The marker lives under RUNNER_TEMP and so is + # private to this job, which is what makes this skip an install this job + # already did rather than adopt whatever cosign a runner image shipped. + # + # Recorded before the install rather than after: a cosign-installer that + # fails takes the job with it, so nothing reads the marker afterwards, and a + # caller that swallows that failure meets the installer's own "needs cosign + # on PATH" check on the next call rather than a silent unverified install. + # + # This gate is duplicated in semver-validation/action.yml, because a + # composite cannot share steps with a sibling. The two copies coordinate + # through the marker path below, so a job mixing both actions only installs + # cosign once: change the path or the cosign-installer pin in one and the + # pair silently stops agreeing. Edit them together. + - name: Check whether this job still needs cosign + id: cosign + if: inputs.verify-signature == 'true' + shell: bash + env: + COSIGN_MARKER: ${{ runner.temp }}/.setup-semstat-cosign-installed + run: | + install=true + if [ -f "$COSIGN_MARKER" ]; then + install=false + elif ! : >"$COSIGN_MARKER"; then + echo "::error::could not record that this job installs cosign at ${COSIGN_MARKER}" + exit 1 + fi + if ! echo "install=${install}" >>"$GITHUB_OUTPUT"; then + echo "::error::could not write whether this job needs cosign to GITHUB_OUTPUT" + exit 1 + fi + + - name: Install cosign + if: inputs.verify-signature == 'true' && steps.cosign.outputs.install == 'true' + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Install semstat + id: install + shell: bash + env: + SEMSTAT_VERSION: ${{ inputs.version }} + SEMSTAT_VERIFY_SIGNATURE: ${{ inputs.verify-signature }} + # Composite steps inherit the job's environment, and the installer takes + # a file:// download root for its own tests. Cleared here, where step env + # wins, so a workflow-level env: or an earlier step writing GITHUB_ENV + # cannot repoint which executable this action runs: the archive and the + # checksums.txt it is verified against come from the same root, so a + # planted release verifies against its own planted manifest, and the + # PATH opt-out because putting semstat on PATH is this action's + # contract, not something a job's environment gets to withdraw. + SEMSTAT_BASE_URL: "" + SEMSTAT_SKIP_PATH: "" + run: ${{ github.action_path }}/src/install-semstat.sh diff --git a/.github/actions/semver-validation/src/install-semstat.sh b/.github/actions/setup-semstat/src/install-semstat.sh similarity index 50% rename from .github/actions/semver-validation/src/install-semstat.sh rename to .github/actions/setup-semstat/src/install-semstat.sh index 423e00d9..cab20a63 100755 --- a/.github/actions/semver-validation/src/install-semstat.sh +++ b/.github/actions/setup-semstat/src/install-semstat.sh @@ -1,26 +1,40 @@ #!/usr/bin/env bash # Downloads the semstat release binary, verifies it against the release -# checksums, and reports where it landed. +# checksums, puts it on PATH and reports where it landed. # # Emits one GitHub Actions step output: # # semstat absolute path to the verified, executable binary # +# and, unless asked not to, appends the directory holding it to GITHUB_PATH, +# because most consumers call semstat from inside a bash function or a `while +# read` loop, where a step output is not in scope. The output is kept as well, +# for a caller that would rather name the binary than depend on PATH ordering. +# # Required environment: -# SEMSTAT_VERSION release tag to install, with or without a leading "v". # GITHUB_OUTPUT standard GitHub Actions step output file. +# GITHUB_PATH standard GitHub Actions path file. # RUNNER_TEMP where to unpack. Per-job and cleared by the runner. # # Optional environment: +# SEMSTAT_VERSION release tag to install, with or without a leading "v". +# Empty or unset installs DEFAULT_VERSION below. +# SEMSTAT_VERIFY_SIGNATURE "true" to cosign-verify checksums.txt against the +# release workflow before trusting it. Needs cosign +# on PATH; the action installs it when asked. # SEMSTAT_BASE_URL file:// release download root. For the tests; see below. +# SEMSTAT_SKIP_PATH "true" to report the binary through the step output only +# and leave GITHUB_PATH alone. For a caller that names the +# binary and has no use for a change to the job's PATH. set -euo pipefail : "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" -: "${SEMSTAT_VERSION:?semstat-version is required}" +: "${GITHUB_PATH:?GITHUB_PATH is required}" # Not defaulted to /tmp: RUNNER_TEMP is private to one job, and the install # directory below is a name anyone could predict. On a runner with a shared # writable /tmp, the fallback would let another user plant a binary there for -# the reuse check to adopt. +# the reuse check to adopt, or plant an empty signature marker over it so that a +# step asking to verify takes the cache-hit path and never runs cosign. : "${RUNNER_TEMP:?RUNNER_TEMP is required; this action expects a GitHub Actions runner}" # Checked up front rather than where each is first reached. A runner missing one @@ -40,30 +54,12 @@ if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1 exit 1 fi -# Named here even though nothing below uses it: jq is what the reporting step of -# this composite reads semstat's output with, and the caller cannot run one step -# without the other. Said before the download rather than after it, so a runner -# missing jq is told so instead of paying for an install it cannot use. -if ! command -v jq >/dev/null 2>&1; then - echo "::error::jq is required to read semstat's output and is not on PATH" - exit 1 -fi - # A workflow command ends at a newline, so anything the downloaded binary prints # has to be folded onto one line before it is echoed back. fold_lines() { printf '%s' "$1" | tr '\r\n' ' ' } -# The path is the whole answer this step gives the next one, so a write that did -# not land has to fail the step rather than hand the caller an empty SEMSTAT_BIN. -emit_path() { - if ! echo "semstat=${binary}" >>"$GITHUB_OUTPUT"; then - echo "::error::could not write the semstat path to GITHUB_OUTPUT" - exit 1 - fi -} - # One of the two is known to be on PATH by the gate above. sha256_of() { if command -v sha256sum >/dev/null 2>&1; then @@ -75,6 +71,19 @@ sha256_of() { BASE_URL="https://github.com/loft-sh/semstat/releases/download" +# The pin lives here rather than in action.yml because both entry points reach +# this script: the action, and a sibling action in this repository running it off +# github.action_path to avoid pinning a sibling by SHA. One line to bump, and no +# way for the two to install different releases. +# renovate: datasource=github-releases depName=loft-sh/semstat +DEFAULT_VERSION=v0.0.2 + +# The workflow that publishes the release, and so the identity its signatures +# carry. Verified at the exact tag rather than at a branch or a glob: a bundle +# signed by this workflow on any other ref does not attest this release. +SIGNING_WORKFLOW="https://github.com/loft-sh/semstat/.github/workflows/release.yaml" +OIDC_ISSUER="https://token.actions.githubusercontent.com" + # The tests serve a release from disk. A remote override is refused rather than # ignored, because composite steps inherit the job's environment: a workflow-level # env: or an earlier step writing GITHUB_ENV would otherwise repoint which @@ -90,9 +99,39 @@ if [ -n "${SEMSTAT_BASE_URL-}" ]; then esac fi -case "$SEMSTAT_VERSION" in - v*) tag="$SEMSTAT_VERSION" ;; - *) tag="v${SEMSTAT_VERSION}" ;; +# Anything but the two booleans is a typo rather than a no, and reading a typo as +# "do not verify" would answer a request for the stricter check with the weaker one. +# +# Unset means false here while both actions default their input to true, because +# the actions are what installs cosign: defaulting this to true as well would make +# a direct run of the script demand a tool nothing in that path provides. Every +# caller that can verify binds this explicitly, so the divergence is only ever +# reached by running the script on its own. +case "${SEMSTAT_VERIFY_SIGNATURE:-false}" in + true) verify_signature=true ;; + false) verify_signature=false ;; + *) + echo "::error::verify-signature takes true or false; got $(fold_lines "$SEMSTAT_VERIFY_SIGNATURE")" + exit 1 + ;; +esac + +# Read the same way as verify-signature, and for the same reason: a typo taken +# for a no would leave the job's PATH changed under a caller that asked for it +# not to be. +case "${SEMSTAT_SKIP_PATH:-false}" in + true) skip_path=true ;; + false) skip_path=false ;; + *) + echo "::error::SEMSTAT_SKIP_PATH takes true or false; got $(fold_lines "$SEMSTAT_SKIP_PATH")" + exit 1 + ;; +esac + +requested="${SEMSTAT_VERSION:-$DEFAULT_VERSION}" +case "$requested" in + v*) tag="$requested" ;; + *) tag="v${requested}" ;; esac version="${tag#v}" @@ -102,7 +141,7 @@ version="${tag#v}" # own, and what keeps path segments out of the URL; the echoes downstream carry # it unfolded because it got through this. if ! [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then - echo "::error::semstat-version must be a semantic version, with or without a leading v; got $(fold_lines "$SEMSTAT_VERSION")" + echo "::error::the version to install must be a semantic version, with or without a leading v; got $(fold_lines "$requested")" exit 1 fi @@ -131,23 +170,55 @@ archive="semstat_${version}_${os}_${arch}.tar.gz" # per-job and the runner clears it, so nothing is carried into another job. install_dir="${RUNNER_TEMP}/semstat-${version}-${os}-${arch}" binary="${install_dir}/semstat" + # Written only after the checksum and the version cross-check passed, and holds # the digest of the binary they passed for. Reuse asks the marker rather than # the binary, because a binary that prints the right version proves nothing # about the rest of itself. marker="${install_dir}/.verified" +# A second marker, because the two questions are different: the digest says this +# is the binary that passed, and this says the release it came out of was +# signature-checked. A step asking to verify is not handed an install an earlier +# unverified step left behind. +verified_marker="${install_dir}/.signature-verified" + +# PATH for the callers that reach semstat from a function or a loop, the output +# for the ones that would rather be explicit about which binary they mean. Both +# are the whole answer this step gives the next one, so a write that did not land +# has to fail the step rather than hand the caller an empty SEMSTAT_BIN. +report_install() { + if ! echo "semstat=${binary}" >>"$GITHUB_OUTPUT"; then + echo "::error::could not write the semstat path to GITHUB_OUTPUT" + exit 1 + fi + if [ "$skip_path" = true ]; then + return + fi + if ! printf '%s\n' "$install_dir" >>"$GITHUB_PATH"; then + echo "::error::could not add ${install_dir} to GITHUB_PATH" + exit 1 + fi +} + # An installed binary is reused only if this script verified it earlier in this -# job, it has not changed since, and it still runs and agrees about its version. +# job, it has not changed since, it still runs and agrees about its version, and +# it was verified as strictly as this step asks. if [ -x "$binary" ] && recorded="$(cat "$marker" 2>/dev/null)" && [ -n "$recorded" ] && [ "$recorded" = "$(sha256_of "$binary")" ] && - cached="$("$binary" version 2>/dev/null)" && [ "${cached#v}" = "$version" ]; then - emit_path + cached="$("$binary" version 2>/dev/null)" && [ "${cached#v}" = "$version" ] && + { [ "$verify_signature" = false ] || [ -f "$verified_marker" ]; }; then + report_install echo "semstat ${version} already installed at ${binary}" exit 0 fi +if [ "$verify_signature" = true ] && ! command -v cosign >/dev/null 2>&1; then + echo "::error::verify-signature needs cosign on PATH; the setup-semstat action installs it, so this is reachable only by running the script directly" + exit 1 +fi + if ! work="$(mktemp -d "${RUNNER_TEMP}/semstat.XXXXXX")" || [ -z "$work" ]; then echo "::error::could not create a working directory under RUNNER_TEMP to download semstat into" exit 1 @@ -159,9 +230,8 @@ trap 'rm -rf "$work"' EXIT # checksums.txt is fetched alongside the archive rather than pinned here, because # the version is an input. It proves the download arrived intact and that the # asset name resolved inside the release we asked for, and `semstat version` -# below proves we unpacked semstat. It is not a signature check and is not meant -# to be one: semstat is ours, so the release is trusted and this catches a bad -# transfer rather than an untrusted publisher. +# below proves we unpacked semstat. It says nothing about who published that +# release, which is the separate question SEMSTAT_VERIFY_SIGNATURE answers. for asset in "$archive" checksums.txt; do status=0 curl -fsSL --retry 3 --retry-connrefused --connect-timeout 10 --max-time 120 -o "${work}/${asset}" "${BASE_URL}/${tag}/${asset}" || status=$? @@ -173,6 +243,51 @@ for asset in "$archive" checksums.txt; do fi done +# Fetched on its own rather than in the loop above, because an absent bundle is a +# different failure than an absent release and must not be reported as one. +# semstat publishes a release only once signing has succeeded: goreleaser cuts it +# as a draft and the release workflow flips that off as its last step, so a Fulcio +# or Rekor outage leaves a deletable draft rather than a published release with no +# provenance. Every published release carries its bundle, the four cut before that +# flow included. +# +# So this branch no longer means an incomplete publish. It means the asset went +# missing from a release still mutable enough to lose it, which is nearer the +# tampering the signature check exists to catch. Either way the only repair the +# reader of this error can make is to name a release that has one: re-releasing a +# tag is semstat's to do, and cannot touch a release cut under immutability. +if [ "$verify_signature" = true ]; then + bundle=checksums.txt.sigstore.json + status=0 + curl -fsSL --retry 3 --retry-connrefused --connect-timeout 10 --max-time 120 -o "${work}/${bundle}" "${BASE_URL}/${tag}/${bundle}" || status=$? + # 22 is curl's HTTP-error status, so the release answered and does not carry + # the asset. Any other status is this runner not reaching it, which is a + # different problem than a release without provenance and must not be reported + # as one. + if [ "$status" -eq 22 ]; then + echo "::error::semstat ${tag} publishes no ${bundle}, so its signature cannot be verified; pin a release that carries one" + exit 1 + elif [ "$status" -ne 0 ]; then + echo "::error::could not download ${bundle} for semstat ${tag}: curl exited ${status}" + exit 1 + fi +fi + +# Before checksums.txt is read rather than after: the signature is what says this +# manifest came from the release workflow, and every integrity claim below rests +# on the manifest. +if [ "$verify_signature" = true ]; then + identity="${SIGNING_WORKFLOW}@refs/tags/${tag}" + if ! cosign_output="$(cosign verify-blob "${work}/checksums.txt" \ + --bundle "${work}/checksums.txt.sigstore.json" \ + --certificate-identity="$identity" \ + --certificate-oidc-issuer="$OIDC_ISSUER" 2>&1)"; then + echo "::error::cosign could not verify checksums.txt for semstat ${tag} as ${identity}: $(fold_lines "$cosign_output")" + exit 1 + fi + echo "cosign verified checksums.txt for semstat ${tag} as ${identity}" +fi + # Pick out our line rather than running `sha256sum -c` over the whole file: # checksums.txt covers every archive and SBOM in the release and only one of # them was downloaded, and an absent line has to be an error rather than the @@ -233,14 +348,35 @@ fi # Moved into place only once verified, so nothing half-installed is left behind # for the reuse check above to find. -if ! mkdir -p "$install_dir" || - ! mv "${work}/semstat" "$binary" || - ! sha256_of "$binary" >"$marker"; then - # The marker is written last, so a failure part-way leaves an install the reuse - # check above will not adopt. +if ! mkdir -p "$install_dir"; then + echo "::error::could not create ${install_dir} to install semstat into" + exit 1 +fi + +# An older signature claim is dropped before this run's binary lands rather than +# after. The reverse order leaves a window where a run killed mid-install has +# replaced the binary but not the marker, so an unverified download sits under an +# earlier run's claim and the reuse check above hands it to a step that asked to +# verify. +if [ "$verify_signature" = false ] && ! rm -f "$verified_marker"; then + echo "::error::could not drop the earlier signature claim at ${verified_marker}, which would leave this unverified install standing under it for a later step asking to verify" + exit 1 +fi + +if ! mv "${work}/semstat" "$binary" || ! sha256_of "$binary" >"$marker"; then + # The digest marker is written last, so a failure part-way leaves an install + # the reuse check above will not adopt. echo "::error::could not install the verified semstat to ${binary}" exit 1 fi -emit_path +# Written after the binary, so the marker never claims more than what is +# installed. A claim that could not be written has to fail the step: every later +# step asking to verify would otherwise re-download for the whole job. +if [ "$verify_signature" = true ] && ! : >"$verified_marker"; then + echo "::error::could not record that semstat ${tag} was signature-verified at ${verified_marker}" + exit 1 +fi + +report_install echo "semstat ${version} installed at ${binary}" diff --git a/.github/actions/semver-validation/test/install-semstat.bats b/.github/actions/setup-semstat/test/install-semstat.bats similarity index 54% rename from .github/actions/semver-validation/test/install-semstat.bats rename to .github/actions/setup-semstat/test/install-semstat.bats index 1e43ec73..f2f9320b 100644 --- a/.github/actions/semver-validation/test/install-semstat.bats +++ b/.github/actions/setup-semstat/test/install-semstat.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# Tests for semver-validation/src/install-semstat.sh +# Tests for setup-semstat/src/install-semstat.sh # # Stubs curl with a local release directory, so the download, the checksum # verification and the version cross-check all run for real against artifacts @@ -14,6 +14,9 @@ setup() { export GITHUB_OUTPUT="$TEST_DIR/github_output" : >"$GITHUB_OUTPUT" + export GITHUB_PATH="$TEST_DIR/github_path" + : >"$GITHUB_PATH" + export RUNNER_TEMP="$TEST_DIR/runner-temp" mkdir -p "$RUNNER_TEMP" @@ -49,6 +52,22 @@ cp "$src" "$dest" MOCK chmod +x "$MOCK_DIR/curl" + export COSIGN_ARGS="$TEST_DIR/cosign_args" + : >"$COSIGN_ARGS" + + # cosign: records the whole invocation and reports OK. COSIGN_EXIT stands in + # for a bundle that does not verify. + cat >"$MOCK_DIR/cosign" <<'MOCK' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$COSIGN_ARGS" +if [ "${COSIGN_EXIT:-0}" -ne 0 ]; then + echo "Error: no matching signatures" >&2 + exit "$COSIGN_EXIT" +fi +echo "Verified OK" >&2 +MOCK + chmod +x "$MOCK_DIR/cosign" + publish_release v1.2.3 1.2.3 } @@ -82,6 +101,11 @@ MOCK done (cd "$dir" && sha256sum ./*.tar.gz | sed 's| \./| |' >checksums.txt) + + # The signature bundle is opaque to the script, which only hands it to cosign, + # so its contents matter no more than that the asset is there to be fetched. + printf '{"mediaType":"application/vnd.dev.sigstore.bundle+json;version=0.3"}\n' \ + >"$dir/checksums.txt.sigstore.json" } # Reads an output back out of GITHUB_OUTPUT. @@ -89,6 +113,16 @@ output_value() { sed -n "s/^$1=//p" "$GITHUB_OUTPUT" | tail -n1 } +# The directories the run put on PATH for the steps that follow it. +path_entries() { + cat "$GITHUB_PATH" +} + +# The assets fetched from the release so far, by name. +requested_assets() { + sed 's|.*/||' "$CURL_URLS" +} + # The archive name the run asked the release for. requested_archive() { grep -o '[^/]*\.tar\.gz$' "$CURL_URLS" | tail -n1 @@ -264,13 +298,58 @@ MOCK [[ "$output" == *"::error::semstat has no build for ppc64le"* ]] } -@test "fails when no version is given" { +@test "installs the pinned release when SEMSTAT_VERSION is unset" { + pinned="$(sed -n 's/^DEFAULT_VERSION=//p' "$SCRIPT")" + [ -n "$pinned" ] + unset SEMSTAT_VERSION run "$SCRIPT" + # The test serves one release from disk and it is not that one, so the download + # fails; which release was asked for is what this is about. + [ "$status" -eq 1 ] + [[ "$(cat "$CURL_URLS")" == *"/${pinned}/"* ]] +} + +@test "fails when GITHUB_PATH is not set" { + unset GITHUB_PATH + + run "$SCRIPT" + + [ "$status" -ne 0 ] + [[ "$output" == *"GITHUB_PATH is required"* ]] +} + +# Refused rather than falling back to /tmp: the install directory carries the +# signature-verified marker, and on a shared /tmp anyone could forge it. +@test "fails when RUNNER_TEMP is not set" { + unset RUNNER_TEMP + + run "$SCRIPT" + [ "$status" -ne 0 ] - [[ "$output" == *"semstat-version is required"* ]] + [[ "$output" == *"RUNNER_TEMP is required"* ]] + [ ! -s "$CURL_URLS" ] +} + +@test "leaves no work directory behind once installed" { + run "$SCRIPT" + + [ "$status" -eq 0 ] + [ -z "$(find "$RUNNER_TEMP" -maxdepth 1 -name 'semstat.*' -print -quit)" ] +} + +# The archive and any half-unpacked binary go with it, on every exit path rather +# than only the one that succeeded. +@test "leaves no work directory behind when the download unpacks the wrong version" { + publish_release v1.2.4 1.2.3 + export SEMSTAT_VERSION=v1.2.4 + + run "$SCRIPT" + + [ "$status" -eq 1 ] + [ -z "$(find "$RUNNER_TEMP" -maxdepth 1 -name 'semstat.*' -print -quit)" ] } @test "refuses a version that is not a semantic version" { @@ -306,17 +385,6 @@ MOCK [ "$("$(output_value semstat)" version)" = "1.4.0-rc.1" ] } -@test "fails when RUNNER_TEMP is not set" { - # The fallback this replaces put the install directory at a predictable path - # under a /tmp anyone on the runner can write to. - unset RUNNER_TEMP - - run "$SCRIPT" - - [ "$status" -ne 0 ] - [[ "$output" == *"RUNNER_TEMP is required"* ]] -} - @test "refuses an archive whose semstat member is a symlink" { local dir="$RELEASE_DIR/v1.5.0" local staging="$TEST_DIR/staging-symlink" @@ -452,7 +520,7 @@ PLANTED [ -z "$(output_value semstat)" ] } -@test "a failed install leaves no work directory behind under RUNNER_TEMP" { +@test "leaves no work directory behind when the release does not exist" { export SEMSTAT_VERSION=v9.9.9 run "$SCRIPT" @@ -461,13 +529,6 @@ PLANTED [ -z "$(find "$RUNNER_TEMP" -maxdepth 1 -name 'semstat.*' -print -quit)" ] } -@test "a successful install leaves no work directory behind either" { - run "$SCRIPT" - - [ "$status" -eq 0 ] - [ -z "$(find "$RUNNER_TEMP" -maxdepth 1 -name 'semstat.*' -print -quit)" ] -} - # A PATH holding bash, so the shebang resolves, plus the named tools and nothing # else. Used to take one tool away from the gate at a time. gate_path() { @@ -513,16 +574,268 @@ gate_path() { [ ! -s "$CURL_URLS" ] } -@test "a runner without jq says so before the release is downloaded" { - # jq is not used here but in the reporting step of the same composite, so a - # runner missing it is told before the install it cannot use is paid for. - run /usr/bin/env "PATH=$(gate_path curl tar sha256sum)" "$SCRIPT" +# PATH, not just the output: every consumer calls semstat from inside a bash +# function or a `while read` loop, where a step output is not in scope. +@test "puts the directory holding the binary on PATH" { + run "$SCRIPT" + + [ "$status" -eq 0 ] + [ "$(path_entries)" = "$(dirname "$(output_value semstat)")" ] +} + +@test "puts the binary on PATH again when the install is reused" { + run "$SCRIPT" + [ "$status" -eq 0 ] + installed="$(path_entries)" + : >"$GITHUB_PATH" + + run "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"already installed"* ]] + [ "$(path_entries)" = "$installed" ] +} + +# semver-validation runs semstat through SEMSTAT_BIN and never as a bare +# `semstat`, so for it the append is a change to the caller's job with nothing +# reading it. +@test "leaves PATH alone when asked to" { + export SEMSTAT_SKIP_PATH=true + + run "$SCRIPT" + + [ "$status" -eq 0 ] + [ -x "$(output_value semstat)" ] + [ ! -s "$GITHUB_PATH" ] +} + +@test "leaves PATH alone when a reused install is reported" { + export SEMSTAT_SKIP_PATH=true + run "$SCRIPT" + [ "$status" -eq 0 ] + + run "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"already installed"* ]] + [ ! -s "$GITHUB_PATH" ] +} + +@test "refuses a skip-path value that is neither true nor false" { + export SEMSTAT_SKIP_PATH=yes + + run "$SCRIPT" + + [ "$status" -eq 1 ] + [[ "$output" == *"SEMSTAT_SKIP_PATH takes true or false; got yes"* ]] + [ ! -s "$CURL_URLS" ] +} + +@test "leaves the signature alone unless asked" { + run "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$(requested_assets)" != *sigstore* ]] + [ ! -s "$COSIGN_ARGS" ] +} + +@test "verifies checksums.txt against the release workflow at the exact tag" { + export SEMSTAT_VERIFY_SIGNATURE=true + + run "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$(requested_assets)" == *checksums.txt.sigstore.json* ]] + [[ "$(cat "$COSIGN_ARGS")" == *"verify-blob"* ]] + [[ "$(cat "$COSIGN_ARGS")" == *"--bundle "*"checksums.txt.sigstore.json"* ]] + [[ "$(cat "$COSIGN_ARGS")" == *"--certificate-identity=https://github.com/loft-sh/semstat/.github/workflows/release.yaml@refs/tags/v1.2.3"* ]] + [[ "$(cat "$COSIGN_ARGS")" == *"--certificate-oidc-issuer=https://token.actions.githubusercontent.com"* ]] +} + +# A release can carry its archives and checksums.txt and no bundle, so this must +# not be reported as the release being absent. +@test "names the missing signature when the release publishes no bundle" { + export SEMSTAT_VERIFY_SIGNATURE=true + rm "$RELEASE_DIR/v1.2.3/checksums.txt.sigstore.json" + + run "$SCRIPT" + + [ "$status" -eq 1 ] + [[ "$output" == *"publishes no checksums.txt.sigstore.json"* ]] + [[ "$output" != *"could not download checksums.txt.sigstore.json"* ]] + [ -z "$(output_value semstat)" ] +} + +@test "a bundle that could not be fetched is not read as a release without one" { + export SEMSTAT_VERIFY_SIGNATURE=true + + # Fails the bundle alone and hands every other asset to the real mock, so the + # release is there and reachable and only this download is not. + farm="$TEST_DIR/no-bundle-egress" + mkdir -p "$farm" + cat >"$farm/curl" <"$(output_value semstat)" + + run "$SCRIPT" + + [ "$status" -eq 1 ] + [[ "$output" == *"::error::could not record that semstat v1.2.3 was signature-verified"* ]] +} + +# The reverse of the test above: an unverified run that cannot clear the earlier +# claim has to fail rather than install under it, because the reuse check reads +# the claim and would hand this download to a step that asked to verify. +@test "a stale signature claim that cannot be dropped fails the step" { + run "$SCRIPT" + [ "$status" -eq 0 ] + + # A directory where the marker file goes: rm cannot remove it. + dir="$(dirname "$(output_value semstat)")" + mkdir "$dir/.signature-verified" + + # Only running it says the install is unusable, so this forces a re-install. + printf 'truncated' >"$(output_value semstat)" + + run "$SCRIPT" + + [ "$status" -eq 1 ] + [[ "$output" == *"::error::could not drop the earlier signature claim"* ]] +} + +@test "reuses an install that was signature-verified" { + export SEMSTAT_VERIFY_SIGNATURE=true + + run "$SCRIPT" + [ "$status" -eq 0 ] + downloaded="$(download_count)" + + run "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"already installed"* ]] + [ "$(download_count)" -eq "$downloaded" ] +} + +# An unverified re-install must not inherit the earlier run's claim: the next +# verifying step would otherwise reuse a binary nothing verified. +@test "drops the verified marker when a later run does not verify" { + export SEMSTAT_VERIFY_SIGNATURE=true + run "$SCRIPT" + [ "$status" -eq 0 ] + + unset SEMSTAT_VERIFY_SIGNATURE + # Only running it says the install is unusable, so this forces a re-install + # while leaving the marker from the verified run in place. + printf 'truncated' >"$(output_value semstat)" + run "$SCRIPT" + [ "$status" -eq 0 ] + + export SEMSTAT_VERIFY_SIGNATURE=true + downloaded="$(download_count)" + + run "$SCRIPT" + + [ "$status" -eq 0 ] + [ "$(download_count)" -gt "$downloaded" ] +} + @test "a checksums.txt with CRLF line endings still verifies the archive" { # The \r is not whitespace to awk, so unstripped it stayed on the asset name # and the lookup missed, blaming the release for a missing archive that was @@ -538,9 +851,52 @@ gate_path() { @test "a RUNNER_TEMP that cannot be worked in says so rather than dying on mktemp" { export RUNNER_TEMP="$TEST_DIR/never-created" - run "$SCRIPT" [ "$status" -eq 1 ] [[ "$output" == *"::error::could not create a working directory under RUNNER_TEMP"* ]] } + +# An untouched `version:` input arrives as an empty string rather than unset, and +# the pin lives in the script so the action and a sibling action running that +# script off github.action_path cannot install different releases. +@test "installs the pinned release when the version input is empty" { + pinned="$(sed -n 's/^DEFAULT_VERSION=//p' "$SCRIPT")" + [ -n "$pinned" ] + + export SEMSTAT_VERSION="" + run "$SCRIPT" + + [ "$status" -eq 1 ] + [[ "$(cat "$CURL_URLS")" == *"/${pinned}/"* ]] +} + +# The marker is cleared before the binary is moved into place, so a run that dies +# in between cannot leave this run's unverified download under an earlier run's +# claim. Failing the move is how that window is reached from a test. +@test "clears an earlier verified claim even when the install does not complete" { + export SEMSTAT_VERIFY_SIGNATURE=true + run "$SCRIPT" + [ "$status" -eq 0 ] + + marker="$(dirname "$(output_value semstat)")/.signature-verified" + [ -f "$marker" ] + + unset SEMSTAT_VERIFY_SIGNATURE + # Only running it says the install is unusable, so this forces a re-install. + printf 'truncated' >"$(output_value semstat)" + + break_dir="$TEST_DIR/break" + mkdir -p "$break_dir" + cat >"$break_dir/mv" <<'MOCK' +#!/usr/bin/env bash +echo "mv: interrupted" >&2 +exit 1 +MOCK + chmod +x "$break_dir/mv" + + run env PATH="$break_dir:$PATH" "$SCRIPT" + + [ "$status" -ne 0 ] + [ ! -f "$marker" ] +} diff --git a/.github/workflows/test-semver-validation.yaml b/.github/workflows/test-semver-validation.yaml index 3b9063dc..40673cbc 100644 --- a/.github/workflows/test-semver-validation.yaml +++ b/.github/workflows/test-semver-validation.yaml @@ -5,10 +5,16 @@ on: branches: [main] paths: - '.github/actions/semver-validation/**' + # The action runs setup-semstat's installer out of the same checkout, so a + # change there is a change to this one, and the end-to-end job runs it. + - '.github/actions/setup-semstat/**' - '.github/workflows/test-semver-validation.yaml' pull_request: paths: - '.github/actions/semver-validation/**' + # The action runs setup-semstat's installer out of the same checkout, so a + # change there is a change to this one, and the end-to-end job runs it. + - '.github/actions/setup-semstat/**' - '.github/workflows/test-semver-validation.yaml' permissions: {} @@ -35,16 +41,32 @@ jobs: name: Run the action end to end runs-on: ubuntu-latest timeout-minutes: 10 + # A job environment that would repoint the installer if the action left any + # of these unbound: an older release, a download root serving nothing, and a + # demand that semstat land on the job's PATH after all. Composite steps + # inherit it, so the steps below answering at all is the assertion. bats + # covers the script; only a real job covers the binding. + env: + SEMSTAT_VERSION: v0.0.1 + SEMSTAT_BASE_URL: file:///nonexistent + SEMSTAT_SKIP_PATH: "false" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + # The parsing cases below all opt out, against a default that is now on. + # Two reasons: what they assert is the reported version, not the download, + # so paying for cosign on each would hang a Sigstore outage on tests about + # semver; and opting out is what leaves an unverified semstat in the job for + # the verified call further down to have to re-install over. setup-semstat's + # own suite is where the default itself is pinned. - name: A stable version id: stable uses: ./.github/actions/semver-validation with: version: v2.1.0 + verify-signature: false - name: A prerelease, ordered against its final release id: rc @@ -52,18 +74,21 @@ jobs: with: version: v4.9.0-rc.2 compare_to: v4.9.0 + verify-signature: false - name: A version that is not semver id: invalid uses: ./.github/actions/semver-validation with: version: '1.2' + verify-signature: false - name: Valid semver carrying a suffix nothing routes id: unroutable uses: ./.github/actions/semver-validation with: version: v1.11.1-kubernetes.115 + verify-signature: false # A dash-leading argument is an option to semstat, which exits 64 for it. # Only the real binary shows that this action answers instead of failing. @@ -72,6 +97,7 @@ jobs: uses: ./.github/actions/semver-validation with: version: '-1.2.3' + verify-signature: false - name: A compare_to that starts with a dash id: dashed_compare @@ -79,6 +105,7 @@ jobs: with: version: v2.1.0 compare_to: '-2.0.0' + verify-signature: false # semstat parses a padded version and echoes the padding back in raw, so # the trim has to happen before the call. @@ -88,6 +115,97 @@ jobs: with: version: ' v2.1.0 ' compare_to: ' v2.0.9 ' + verify-signature: false + + # Runs the real cosign against the real bundle, and re-installs over the + # unverified semstat the steps above left in the job, which is the whole + # path a publish job asking to verify takes. + - name: A version answered from a signature-verified semstat + id: signed + uses: ./.github/actions/semver-validation + with: + version: v4.9.0 + verify-signature: true + + # Two versions are two inputs to this action, not two semstat releases, so + # the call below would otherwise reuse the install the step above verified + # and exit before the cosign gate. Dropping the install is what forces it + # to verify again; keeping the cosign marker is what makes it do so with + # cosign-installer skipped, which is the pairing under test. + - name: Drop the semstat install, keep the cosign marker + env: + COSIGN_MARKER: ${{ runner.temp }}/.setup-semstat-cosign-installed + run: | + set -euo pipefail + + if [ ! -f "$COSIGN_MARKER" ]; then + echo "::error::the verified call left no cosign marker at ${COSIGN_MARKER}, so the call below would install cosign again rather than exercise the skip" + exit 1 + fi + + rm -rf "${RUNNER_TEMP:?}"/semstat-* + + shopt -s nullglob + remaining=("$RUNNER_TEMP"/semstat-*) + + if [ "${#remaining[@]}" -ne 0 ]; then + echo "::error::a semstat install survived under ${RUNNER_TEMP}, so the next call would take the reuse path instead of verifying: ${remaining[*]}" + exit 1 + fi + + # Re-installs with cosign-installer skipped. If the skip left cosign + # unreachable the installer's own gate fails this step. + - name: A second version answered from the verified semstat + id: signed_again + uses: ./.github/actions/semver-validation + with: + version: v4.9.1 + verify-signature: true + + - name: Assert the second verified call answered + env: + IS_VALID: ${{ steps.signed_again.outputs.is_valid }} + run: | + set -euo pipefail + + if [ "$IS_VALID" != "true" ]; then + echo "::error::the second verify-signature call reported is_valid='${IS_VALID}'" + exit 1 + fi + + # Read off the install directory rather than off PATH, because this action + # asks the installer to leave PATH alone. Exactly one install also says the + # job environment's v0.0.1 never got downloaded, which a PATH lookup could + # not have shown: it only ever names whichever landed last. + - name: Assert the job environment did not repoint the install + run: | + set -euo pipefail + + pinned="$(sed -n 's/^DEFAULT_VERSION=//p' \ + .github/actions/setup-semstat/src/install-semstat.sh)" + + shopt -s nullglob + installs=("$RUNNER_TEMP"/semstat-*/semstat) + + if [ "${#installs[@]}" -ne 1 ]; then + echo "::error::expected one semstat install under RUNNER_TEMP, found ${#installs[@]}: ${installs[*]}" + exit 1 + fi + + installed="$("${installs[0]}" version)" + if [ "${installed#v}" != "${pinned#v}" ]; then + echo "::error::expected the pinned ${pinned}, got ${installed}" + exit 1 + fi + + - name: Assert the action left the job's PATH alone + run: | + set -euo pipefail + + if command -v semstat >/dev/null 2>&1; then + echo "::error::semstat is on this job's PATH; this action names the binary and asks the installer to skip the append" + exit 1 + fi - name: Assert the outputs env: @@ -122,6 +240,8 @@ jobs: PADDED_IS_VALID: ${{ steps.padded.outputs.is_valid }} PADDED_PARSED: ${{ steps.padded.outputs.parsed_version }} PADDED_COMPARISON: ${{ steps.padded.outputs.comparison }} + SIGNED_IS_VALID: ${{ steps.signed.outputs.is_valid }} + SIGNED_TYPE: ${{ steps.signed.outputs.release_type }} run: | set -euo pipefail @@ -180,4 +300,9 @@ jobs: '{"major":2,"minor":1,"patch":0,"prerelease":null,"build":null,"raw":"v2.1.0"}' assert "padded comparison" "$PADDED_COMPARISON" 1 + # Verifying the release changes how semstat was installed, not what it + # answers. + assert "signed is_valid" "$SIGNED_IS_VALID" true + assert "signed release_type" "$SIGNED_TYPE" stable + exit "$fail" diff --git a/.github/workflows/test-setup-semstat.yaml b/.github/workflows/test-setup-semstat.yaml new file mode 100644 index 00000000..3200b751 --- /dev/null +++ b/.github/workflows/test-setup-semstat.yaml @@ -0,0 +1,288 @@ +name: Test setup-semstat + +on: + push: + branches: [main] + paths: + - '.github/actions/setup-semstat/**' + - '.github/workflows/test-setup-semstat.yaml' + pull_request: + paths: + - '.github/actions/setup-semstat/**' + - '.github/workflows/test-setup-semstat.yaml' + +permissions: {} + +jobs: + bats: + name: Run bats tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 + with: + support-install: false + assert-install: false + detik-install: false + file-install: false + - name: Run setup-semstat tests + run: bats .github/actions/setup-semstat/test/*.bats + + action: + name: Run the action end to end + runs-on: ubuntu-latest + timeout-minutes: 10 + # A job environment that would repoint the installer if the action left any + # of these unbound: an older release, a download root serving nothing, and a + # demand that semstat stay off the job's PATH. Composite steps inherit it, so + # the steps below answering at all is the assertion. bats covers the script; + # only a real job covers the binding. + env: + SEMSTAT_VERSION: v0.0.1 + SEMSTAT_BASE_URL: file:///nonexistent + SEMSTAT_SKIP_PATH: "true" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # Pinned off rather than left to the default, which is now on. This job is + # about the env binding and the PATH append, so paying for cosign here would + # hang a Sigstore outage on a test that has nothing to do with signatures. + # It doubles as the coverage that the opt-out is still honoured. + - name: Install semstat + id: install + uses: ./.github/actions/setup-semstat + with: + verify-signature: false + + - name: Assert opting out installed no cosign + env: + COSIGN_MARKER: ${{ runner.temp }}/.setup-semstat-cosign-installed + run: | + set -euo pipefail + + if [ -f "$COSIGN_MARKER" ]; then + echo "::error::verify-signature: false still recorded a cosign install at ${COSIGN_MARKER}" + exit 1 + fi + + # The reason the action appends to GITHUB_PATH at all: this is the shape + # every consumer uses, semstat called by name from inside a function. + - name: Call semstat by name from a shell function + run: | + set -euo pipefail + + newer_than() { + semstat gt "$1" "$2" + } + + if ! newer_than v4.9.0 v4.9.0-rc.2; then + echo "::error::a release should outrank its own candidate" + exit 1 + fi + + # gt is an answer, not a failure, so a no has to stay green. + if newer_than v4.9.0-rc.2 v4.9.0; then + echo "::error::a candidate should not outrank its release" + exit 1 + fi + + - name: Assert the job environment did not repoint the install + run: | + set -euo pipefail + + pinned="$(sed -n 's/^DEFAULT_VERSION=//p' \ + .github/actions/setup-semstat/src/install-semstat.sh)" + installed="$(semstat version)" + + if [ "${installed#v}" != "${pinned#v}" ]; then + echo "::error::expected the pinned ${pinned}, got ${installed}" + exit 1 + fi + + - name: Call semstat through the path output + env: + SEMSTAT_BIN: ${{ steps.install.outputs.path }} + run: | + set -euo pipefail + + if [ ! -x "$SEMSTAT_BIN" ]; then + echo "::error::the path output is not an executable: '$SEMSTAT_BIN'" + exit 1 + fi + + "$SEMSTAT_BIN" validate v4.9.0-rc.2 + + # The inode, not just the path: path is built from the version and platform + # alone, so it is identical whether the second call reused the install or + # re-downloaded over it. A re-download moves a new file into place, which + # changes the inode. + - name: Note which file the first install left behind + id: before + env: + SEMSTAT_BIN: ${{ steps.install.outputs.path }} + run: echo "inode=$(stat -c %i "$SEMSTAT_BIN")" >>"$GITHUB_OUTPUT" + + # Has to ask for the same verification as the install above, or it is not + # the same install being asked for: the reuse path requires a signature + # claim at least as strong as the one requested, so leaving this to the + # default would re-download to verify, move a new binary into place, and + # fail the inode check below for a reason that is not a reuse bug. + - name: Install semstat again + id: reinstall + uses: ./.github/actions/setup-semstat + with: + verify-signature: false + + - name: Assert the second install reused the first + env: + FIRST: ${{ steps.install.outputs.path }} + SECOND: ${{ steps.reinstall.outputs.path }} + BEFORE_INODE: ${{ steps.before.outputs.inode }} + run: | + set -euo pipefail + + if [ "$FIRST" != "$SECOND" ]; then + echo "::error::expected the same path; got '$FIRST' then '$SECOND'" + exit 1 + fi + + after="$(stat -c %i "$SECOND")" + if [ "$after" != "$BEFORE_INODE" ]; then + echo "::error::the release was downloaded again; inode went from $BEFORE_INODE to $after" + exit 1 + fi + + default_verifies: + name: Verify by default + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # No verify-signature input at all, which is the whole point: a caller that + # says nothing gets the verified path. Left untested, the default could be + # flipped back to false by an edit to action.yml with every other job here + # still green, since they all name the value they want. + - name: Install semstat without saying anything about signatures + id: install + uses: ./.github/actions/setup-semstat + + # The marker is the observable. Nothing writes it unless the action decided + # this run had to verify, so its presence is the default resolving to true + # rather than an assertion about cosign being installed generally. + - name: Assert the default took the verifying path + env: + COSIGN_MARKER: ${{ runner.temp }}/.setup-semstat-cosign-installed + SEMSTAT_BIN: ${{ steps.install.outputs.path }} + run: | + set -euo pipefail + + if [ ! -f "$COSIGN_MARKER" ]; then + echo "::error::no cosign install recorded at ${COSIGN_MARKER}; verify-signature defaulted to false" + exit 1 + fi + + "$SEMSTAT_BIN" validate v4.9.0 + + signature: + name: Verify the release signature + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # Runs the real cosign against the real bundle, which is the half the bats + # suite stubs out. A change to how semstat's release workflow signs, or to + # the identity it signs as, fails here. + - name: Install semstat with signature verification + id: install + uses: ./.github/actions/setup-semstat + with: + verify-signature: true + + - name: Assert it installed + env: + SEMSTAT_BIN: ${{ steps.install.outputs.path }} + run: | + set -euo pipefail + + if [ ! -x "$SEMSTAT_BIN" ]; then + echo "::error::the path output is not an executable: '$SEMSTAT_BIN'" + exit 1 + fi + + "$SEMSTAT_BIN" validate v4.9.0 + + # Reaching the cosign gate a second time takes deleting the install: the + # reuse fast path exits before both the `command -v cosign` check and + # verify-blob, so a second call left to hit the cache proves only that the + # cache holds, and would stay green even if the skipped install had left no + # cosign at all. The cosign marker is deliberately kept, because that is + # what makes the call below skip cosign-installer and still have to verify. + - name: Drop the semstat install, keep the cosign marker + env: + COSIGN_MARKER: ${{ runner.temp }}/.setup-semstat-cosign-installed + run: | + set -euo pipefail + + if [ ! -f "$COSIGN_MARKER" ]; then + echo "::error::the first verified install left no cosign marker at ${COSIGN_MARKER}, so the call below would install cosign again rather than exercise the skip" + exit 1 + fi + + rm -rf "${RUNNER_TEMP:?}"/semstat-* + + # One surviving directory would put the next call back on the fast path + # and quietly restore the hole this step exists to close. + shopt -s nullglob + remaining=("$RUNNER_TEMP"/semstat-*) + + if [ "${#remaining[@]}" -ne 0 ]; then + echo "::error::a semstat install survived under ${RUNNER_TEMP}, so the next call would take the reuse path instead of verifying: ${remaining[*]}" + exit 1 + fi + + # A real install with cosign-installer skipped: it re-downloads, and the + # installer's own "verify-signature needs cosign on PATH" gate fails the + # step if the skip left cosign unreachable. That is the assertion. + - name: Install again with signature verification + id: reverify + uses: ./.github/actions/setup-semstat + with: + verify-signature: true + + - name: Assert the second verified install answered too + env: + FIRST: ${{ steps.install.outputs.path }} + SECOND: ${{ steps.reverify.outputs.path }} + run: | + set -euo pipefail + + if [ "$SECOND" != "$FIRST" ]; then + echo "::error::the second install reported '$SECOND', expected '$FIRST'" + exit 1 + fi + + "$SECOND" validate v4.9.0 + + - name: Reject a verify-signature value that is not a boolean + id: bad_input + continue-on-error: true + uses: ./.github/actions/setup-semstat + with: + verify-signature: sometimes + + - name: Assert the bad value failed the step + if: steps.bad_input.outcome != 'failure' + run: | + echo "::error::verify-signature: sometimes should have failed the install step" + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 23feed1e..7a98bf3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,18 @@ git push origin /v2 --force For `release-notification`, the `notify-release.yaml` wrapper and its inner composite both resolve at `@release-notification/v2` (see PR #147), so advancing that one tag moves the wrapper and composite together. +### The other half: do NOT advance a tag when the new code asks more of the caller + +Advancing is right for a fix and wrong for a change that adds a requirement, because it reaches every floating-tag caller with no change on their side and no version for them to notice. Cut the next major instead and leave the existing tags alone. Treat as a new major: a new runner requirement or tool, network egress, a token or permission, or anything that changes the caller's job environment (writing `GITHUB_PATH` or `GITHUB_ENV`). + +Check who pins the tag before touching it — the answer is often more repos than expected, and some pin the tag while others pin its SHA: + +```bash +gh search code 'loft-sh/github-actions/.github/actions/@ org:loft-sh' +``` + +`semver-validation` is the worked example (DEVOPS-1369). `v1`, `v2` and `v3` are the self-contained Node action; the rewrite as a composite over `semstat` needs egress, needs `curl`/`tar`/`sha256sum`/`jq`, verifies its download by default and so needs Sigstore egress too, and drops the `semstat_version` input, so it shipped as `v4`. Advancing `v1` or `v3` would have changed five live release workflows across five repos. It does not touch the caller's `PATH`, which would have been a fourth reason on its own: it binds `SEMSTAT_SKIP_PATH` precisely so the rewrite stays invisible to the job around it. + ### YAML-only / composite / Node.js actions (semver-validation, release-notification, etc.) - Update code and commit changes - Tag the release: `git tag -f /v1` diff --git a/Makefile b/Makefile index d4655945..66ca9070 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test test-semver-validation test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-publish-helm-chart test-govulncheck test-go-licenses test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze test-prerelease-setup test-vcluster-release test-subtree-mirror test-oss-commit-sync test-backport-legacy-allowlist test-backport-legacy-split test-promote-release test-wait-for-release test-cve-scan test-commitlint test-resolve-github-release build-linear-release-sync lint check-bats-jobs install-auto-doc generate-docs check-docs help +.PHONY: test test-semver-validation test-setup-semstat test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-publish-helm-chart test-govulncheck test-go-licenses test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze test-prerelease-setup test-vcluster-release test-subtree-mirror test-oss-commit-sync test-backport-legacy-allowlist test-backport-legacy-split test-promote-release test-wait-for-release test-cve-scan test-commitlint test-resolve-github-release build-linear-release-sync lint check-bats-jobs install-auto-doc generate-docs check-docs help ACTIONS_DIR := .github/actions WORKFLOWS_DIR := .github/workflows @@ -93,11 +93,14 @@ check-docs: generate-docs ## verify docs are up to date (fails if drift detected help: ## show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-30s %s\n", $$1, $$2}' -test: test-semver-validation test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-ci-test-notify test-go-licenses test-publish-helm-chart test-govulncheck test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze test-prerelease-setup test-vcluster-release test-subtree-mirror test-oss-commit-sync test-backport-legacy-allowlist test-backport-legacy-split test-promote-release test-wait-for-release test-cve-scan test-commitlint test-resolve-github-release ## run all action tests +test: test-semver-validation test-setup-semstat test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-ci-test-notify test-go-licenses test-publish-helm-chart test-govulncheck test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter test-release-branch-freeze test-prerelease-setup test-vcluster-release test-subtree-mirror test-oss-commit-sync test-backport-legacy-allowlist test-backport-legacy-split test-promote-release test-wait-for-release test-cve-scan test-commitlint test-resolve-github-release ## run all action tests test-semver-validation: ## run semver-validation bats tests bats $(ACTIONS_DIR)/semver-validation/test/*.bats +test-setup-semstat: ## run setup-semstat bats tests + bats $(ACTIONS_DIR)/setup-semstat/test/*.bats + test-linear-pr-commenter: ## run linear-pr-commenter unit tests cd $(ACTIONS_DIR)/linear-pr-commenter/src && go test -v ./... diff --git a/README.md b/README.md index 9d900c39..b71e6121 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ are written, tested, and structured. Reports on a version string: validity, its parts, its release channel, and how it orders against another version. Runs [`semstat`](https://github.com/loft-sh/semstat), -which it downloads and checksum-verifies at a pinned release, so the runner needs -egress to the semstat releases and `curl`, `tar`, `sha256sum` and `jq` on it. That -is new in `semver-validation/v4`; the tags before it (`v1`, `v2` and `v3`) point at -the self-contained Node action, which needed neither, and stay available. +installed by the same script as `setup-semstat` below, so the runner needs egress to +the semstat releases and `curl`, `tar`, `sha256sum` and `jq` on it. That is new in +`semver-validation/v4`; the tags before it (`v1`, `v2` and `v3`) point at the +self-contained Node action, which needed neither, and stay available. **Location:** `.github/actions/semver-validation` @@ -36,7 +36,8 @@ the self-contained Node action, which needed neither, and stay available. - `version` (required): Version string to validate - `compare_to` (optional): Second version to order `version` against -- `semstat_version` (optional): Release of `loft-sh/semstat` to download +- `verify-signature` (optional): cosign-verify the semstat release before + trusting it. Costs a cosign install, so off by default **Outputs:** @@ -51,6 +52,46 @@ the self-contained Node action, which needed neither, and stay available. See [semver-validation README](./.github/actions/semver-validation/README.md) for detailed documentation. +### Setup semstat Action + +Installs [`semstat`](https://github.com/loft-sh/semstat) from a pinned release, +checksum-verifies it, and puts it on `PATH`. This is where the installer and the +release pin live: the actions that answer semver questions run it rather than +carrying a copy of either. Actions in this repository run its script out of the +same checkout; workflows and other repositories use the action below. + +**Location:** `.github/actions/setup-semstat` + +**Usage:** + +```yaml +- name: Install semstat + id: semstat + uses: loft-sh/github-actions/.github/actions/setup-semstat@ # setup-semstat/v1 + with: + verify-signature: true # optional, off by default + +- name: Order two versions from a shell function + run: | + newer_than() { semstat gt "$1" "$2"; } + newer_than v4.9.0 v4.9.0-rc.2 +``` + +**Inputs:** + +- `version` (optional): Release of `loft-sh/semstat` to install. Empty takes the + Renovate-tracked pin in `src/install-semstat.sh` +- `verify-signature` (optional): cosign-verify `checksums.txt` against semstat's + release workflow at that exact tag. Costs a cosign install, so off by default + +**Outputs:** + +- `path`: Absolute path to the verified binary. The directory holding it is on + `PATH` for later steps as well, since consumers call semstat from inside shell + functions and loops where a step output is not in scope + +See [setup-semstat README](./.github/actions/setup-semstat/README.md) for detailed documentation. + ### Linear Release Sync Action Syncs Linear issues to the "Released" state when a GitHub release is published. Finds PRs between releases, extracts Linear issue IDs, and moves matching issues from "Ready for Release" to "Released". @@ -1006,6 +1047,7 @@ Each testable action has a dedicated workflow that runs its tests on PRs when the action's files change: - `test-semver-validation.yaml` - triggers on `.github/actions/semver-validation/**` +- `test-setup-semstat.yaml` - triggers on `.github/actions/setup-semstat/**` - `test-linear-pr-commenter.yaml` - triggers on `.github/actions/linear-pr-commenter/**` - `test-link-backport-prs.yaml` - triggers on `.github/actions/link-backport-prs/**` - `test-linear-release-sync.yaml` - triggers on `.github/actions/linear-release-sync/**` @@ -1144,6 +1186,26 @@ git tag -f action-name/v1 git push origin action-name/v1 --force ``` +These tags float, and callers pin them by name, so a force-push reaches every one +of those callers on their next run with no change on their side. That is the point +when shipping a fix, and the wrong tool when the new code asks something of the +caller that the old code did not: a runner requirement, network egress, a token, a +permission, a change to the job's environment. Cut the next major instead and leave +the existing tags where they are, so callers meet the new requirement when they +choose to move. + +`semver-validation` is the worked example. `v1`, `v2` and `v3` are the self-contained +Node implementation; the composite over `semstat` needs egress to the semstat +releases, needs `curl`/`tar`/`sha256sum`/`jq` on the runner, verifies its download +by default and so needs Sigstore egress too, and drops the `semstat_version` input, +so it went out as `v4` rather than over any of them. It leaves the job's `PATH` +alone, which would otherwise have been a reason on its own. Before force-pushing a +tag, check who pins it: + +```bash +gh search code 'loft-sh/github-actions/.github/actions/@ org:loft-sh' +``` + ### Referencing Actions in Workflows ```yaml diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index b4008642..271bde15 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -11,7 +11,7 @@ Three tiers based on complexity: |---|---|---| | **YAML-only composite** | Thin glue wiring inputs to third-party actions, no custom logic | `release-notification` | | **Compiled action (Go/Node.js)** | Business logic, API calls, data transformation | `linear-pr-commenter` (Go), `linear-release-sync` (Go) | -| **Composite over a released binary** | The logic already ships as a versioned CLI; the action installs it, verifies it, and maps its answers to outputs | `semver-validation` (over `semstat`) | +| **Composite over a released binary** | The logic already ships as a versioned CLI; one action installs and verifies it, the others map its answers to outputs | `setup-semstat` (installs `semstat`), `semver-validation` (maps it) | | **Reusable workflow** | Cross-repo orchestration of multiple jobs | `backport.yaml`, `actionlint.yaml` | **Key rule:** Business logic (branching, loops, parsing, API calls) MUST live in diff --git a/renovate.json b/renovate.json index 33d2907c..366c6fc2 100644 --- a/renovate.json +++ b/renovate.json @@ -43,6 +43,12 @@ "depNameTemplate": "renovate", "datasourceTemplate": "npm" }, + { + "customType": "regex", + "description": "Update the semstat release pinned in the setup-semstat installer", + "managerFilePatterns": ["/^\\.github/actions/setup-semstat/src/install-semstat\\.sh$/"], + "matchStrings": ["# renovate: datasource=(?\\S+) depName=(?\\S+)\\s+DEFAULT_VERSION=(?\\S+)"] + }, { "customType": "regex", "description": "Update versions annotated with `# renovate: datasource=X depName=Y` in workflow inputs and composite action inputs",