diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6114cb4..7db564f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -117,6 +117,40 @@ jobs: fi mago --version | grep -F "$RESOLVED" + # A project that installs mago from its release archive states the version, and often the + # checksum, under "extra" -- and the checksum is then enforced without the workflow repeating + # it. The wrong one has to stop the install, or stating it would mean nothing. + - id: stated + uses: ./ + with: + working-directory: test/fixtures/extra + - shell: bash + env: + RESOLVED: ${{ steps.stated.outputs.version }} + VERIFIED: ${{ steps.stated.outputs.sha256 }} + run: | + echo "resolved: $RESOLVED against $VERIFIED" + if [ "$RESOLVED" != "$MAGO_VERSION" ]; then + echo "::error::extra.mago-version states $MAGO_VERSION, but $RESOLVED was installed" + exit 1 + fi + if [ "$VERIFIED" != "$MAGO_SHA256_LINUX_X64" ]; then + echo "::error::extra.mago-sha256 was not used; the archive was verified against $VERIFIED" + exit 1 + fi + mago --version | grep -F "$RESOLVED" + + - id: stated-wrong + continue-on-error: true + uses: ./ + with: + working-directory: test/fixtures/extra-wrong + - if: steps.stated-wrong.outcome != 'failure' + shell: bash + run: | + echo "::error::a mismatched extra.mago-sha256 was accepted" + exit 1 + # A directory that names no mago at all falls back to the latest release. - id: fallback uses: ./ diff --git a/README.md b/README.md index 02b8611..58f98c7 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,49 @@ here, for that reason. |---|---|---| | `version` | detected | Version to install, without a leading `v` (e.g. `1.29.0`), or `latest`. Unset means: read the project's composer files. | | `working-directory` | workspace | Where the composer files are read from when `version` is unset. | -| `sha256` | — | Expected sha256 of the release archive. Verified before install when set. | +| `sha256` | — | Expected sha256 of the release archive. Verified before install when set, and can be stated in `composer.json` instead. | | `token` | — | Authenticates the git request that lists tags, and nothing else. | | Output | Description | |---|---| | `version` | The version installed, with `latest` and any range resolved. | +| `sha256` | The checksum the archive was verified against, empty when none was stated. | + +With no `version`, the version comes from the project itself, in this order: `extra.mago-version` +in `composer.json`, then the `carthage-software/mago` entry in `composer.lock`, then the one in +`composer.json`, and the latest release if the project states none. A requirement may be a range +(`^1.29`, `~1.29.0`, `>=1.0 <2.0`, `1.29.*`, `a || b`), and then the newest release inside it is +installed. + +`extra` comes first because a project that installs mago from its release archive rather than +through composer has nowhere else to put the version — and it can put the checksum beside it: + +```json +{ + "extra": { + "mago-version": "1.29.0", + "mago-sha256": "5e99d1232fa93e6adc6feaaddaf2b46c148b2990173cdcf18400b474646bf046" + } +} +``` + +Then no workflow repeats either value. The checksum differs per platform, so a project that +installs on more than one states it per target triple: + +```json +{ + "extra": { + "mago-version": "1.29.0", + "mago-sha256": { + "x86_64-unknown-linux-musl": "5e99d1232fa93e6adc6feaaddaf2b46c148b2990173cdcf18400b474646bf046", + "aarch64-apple-darwin": "…" + } + } +} +``` -With no `version`, the version comes from the project itself: the `carthage-software/mago` entry in -`composer.lock`, then the one in `composer.json`, and the latest release if neither names mago. A -`composer.json` may state a range (`^1.29`, `~1.29.0`, `>=1.0 <2.0`, `1.29.*`, `a || b`), and then -the newest release inside it is installed. +A stated checksum is only ever used for the version stated next to it, so a workflow that pins a +different `version` does not get it applied to the wrong archive. The `sha256` input wins over it. None of that spends REST API quota. `latest` is read from where the releases page redirects to, which names the tag, and a range is matched against `git ls-remote --tags`, which is the git diff --git a/action.yml b/action.yml index afa492c..7d707d4 100644 --- a/action.yml +++ b/action.yml @@ -6,14 +6,15 @@ inputs: version: description: >- Version of mago to install, without a leading "v" (e.g. "1.29.0"), or "latest". Leave it - unset to take the version from the project's composer.lock, then its composer.json, and - fall back to the latest release when neither names mago. + unset to take the version the project states -- extra.mago-version in composer.json, then + composer.lock, then the composer.json requirement -- and fall back to the latest release + when it states none. required: false default: "" working-directory: description: >- - Directory the composer.lock and composer.json are read from when no version is given. - Defaults to the workspace. + Directory the composer.json and composer.lock are read from, for the version and for the + checksum. Defaults to the workspace. required: false default: "" token: @@ -26,8 +27,9 @@ inputs: default: "" sha256: description: >- - Expected sha256 of the release archive, verified before the binary is installed. - Checked only when set, since the value differs per version and per platform. + Expected sha256 of the release archive, verified before the binary is installed. Checked + only when set, since the value differs per version and per platform. A project can state it + in composer.json instead, as extra.mago-sha256, and this input wins over that. required: false default: "" @@ -35,6 +37,9 @@ outputs: version: description: The version that was installed, with "latest" and any range resolved. value: ${{ steps.resolve.outputs.version }} + sha256: + description: The checksum the archive was verified against, empty when none was stated. + value: ${{ steps.resolve.outputs.sha256 }} runs: using: composite @@ -67,47 +72,60 @@ runs: action_path=$GITHUB_ACTION_PATH if [ "$OS" = Windows ]; then action_path=$(cygpath -u "$GITHUB_ACTION_PATH"); fi + # Where the composer files live matters even when a version was given, since a checksum + # can still come from them. + root=${WORKING_DIRECTORY:-$PWD} + if [ ! -d "$root" ]; then + echo "::warning::working-directory \"$root\" does not exist; reading $PWD instead" + root=$PWD + fi + # With no version given, the project's own composer files decide, and the latest release # is the fallback when they say nothing about mago. spec=$VERSION if [ -z "$spec" ]; then - root=${WORKING_DIRECTORY:-$PWD} - if [ ! -d "$root" ]; then - echo "::warning::working-directory \"$root\" does not exist; reading $PWD instead" - root=$PWD - fi spec=$(bash "$action_path/bin/version.sh" detect "$root") if [ -n "$spec" ]; then echo "the composer files in $root ask for mago $spec"; fi fi version=$(bash "$action_path/bin/version.sh" resolve "$spec") echo "installing mago $version" + # A checksum given in the workflow wins. Otherwise the project may state one for exactly + # this archive, which is where it belongs: next to the version it verifies. + sha256=$SHA256 + if [ -z "$sha256" ]; then + sha256=$(bash "$action_path/bin/version.sh" checksum "$root" "$triple" "$version") + if [ -n "$sha256" ]; then echo "composer.json states a checksum for this archive"; fi + fi + # Each pin gets its own directory, so two installs in one job never share a path. Sharing # one would let actions/cache save whatever an earlier install left there under this # install's key, and a later run restoring that key would skip the download -- and with it # the checksum -- for a binary this pin never verified. - dir="$HOME/.cache/setup-mago/$triple/$version/${SHA256:-unpinned}" + dir="$HOME/.cache/setup-mago/$triple/$version/${sha256:-unpinned}" native=$dir if [ "$OS" = Windows ]; then native=$(cygpath -w "$dir"); fi + # The checksum is part of the cache key, so changing a pin re-downloads and re-verifies + # rather than handing back what an earlier, differently-pinned run cached. The leading 2 + # is the layout, not the tool: entries written under the old shared path are not + # restorable into this one and must not be reachable by key. { + echo "cache-key=mago-2-$triple-$version-$sha256" echo "triple=$triple" echo "archive=$archive" echo "binary=$binary" echo "version=$version" + echo "sha256=$sha256" echo "dir=$dir" echo "native-dir=$native" } >> "$GITHUB_OUTPUT" - # The checksum is part of the key: changing it should re-download and re-verify rather than - # hand back the binary an earlier, differently-pinned run cached. - id: cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.resolve.outputs.native-dir }} - # The leading 2 is the layout, not the tool: entries written under the old shared path are - # not restorable into this one and must not be reachable by key. - key: mago-2-${{ steps.resolve.outputs.triple }}-${{ steps.resolve.outputs.version }}-${{ inputs.sha256 }} + key: ${{ steps.resolve.outputs.cache-key }} # The release asset is served by the CDN rather than the REST API, so this costs no quota. It # lands in a temporary directory, and the install directory is not created until the archive @@ -116,7 +134,7 @@ runs: shell: bash env: VERSION: ${{ steps.resolve.outputs.version }} - SHA256: ${{ inputs.sha256 }} + SHA256: ${{ steps.resolve.outputs.sha256 }} TRIPLE: ${{ steps.resolve.outputs.triple }} ARCHIVE: ${{ steps.resolve.outputs.archive }} BINARY: ${{ steps.resolve.outputs.binary }} diff --git a/bin/version.sh b/bin/version.sh index 9b855e6..ef36b1b 100755 --- a/bin/version.sh +++ b/bin/version.sh @@ -7,6 +7,8 @@ # of the repository's hourly quota; asking the API to list releases is what this action avoids. # # version.sh detect print the mago constraint the composer files ask for, if any +# version.sh checksum +# print the sha256 the composer files state for that archive, if any # version.sh pick print the newest version on stdin that satisfies # version.sh resolve print the concrete version means, consulting the network # @@ -154,14 +156,19 @@ EOF if [ -n "$_best" ]; then unkey "$_best"; fi } -# The constraint the project already states: the lockfile first, since it pins one version, then -# the manifest, which may instead name a range. +# The version the project already states. "extra" comes first: a project that installs mago from +# its release archive rather than through composer has nowhere else to put the version, and saying +# it there is a statement about the binary, where the lockfile entry is about the package. detect() { _root=$1 if ! command -v jq >/dev/null 2>&1; then warn "jq is not installed, so composer.lock and composer.json cannot be read for a mago version" return 0 fi + if [ -f "$_root/composer.json" ]; then + _stated=$(jq -r '.extra["mago-version"] // empty' "$_root/composer.json" 2>/dev/null || true) + if [ -n "$_stated" ]; then printf '%s\n' "$_stated"; return 0; fi + fi if [ -f "$_root/composer.lock" ]; then _locked=$(jq -r --arg p "$PACKAGE" \ '[(.["packages-dev"] // [])[], (.packages // [])[]] | map(select(.name == $p)) | .[0].version // empty' \ @@ -177,6 +184,25 @@ detect() { return 0 } +# The checksum the project states for the archive, if it states one, and only for the version it +# stated alongside it: a checksum belongs to one archive, so it must not be carried over to a +# version somebody else chose. The value is per-platform, so an object keyed by target triple is +# the honest form; a bare string is taken as given, which is right for a project that installs on +# one platform and fails loudly rather than quietly on another. +checksum() { + _root=$1 + _triple=$2 + _version=$3 + if ! command -v jq >/dev/null 2>&1; then return 0; fi + if [ ! -f "$_root/composer.json" ]; then return 0; fi + _stated=$(jq -r '.extra["mago-version"] // empty' "$_root/composer.json" 2>/dev/null || true) + if [ "${_stated#v}" != "$_version" ]; then return 0; fi + jq -r --arg t "$_triple" \ + '(.extra["mago-sha256"] // empty) as $s + | if ($s | type) == "object" then ($s[$t] // empty) else $s end' \ + "$_root/composer.json" 2>/dev/null || true +} + # The tag list is the one request a token can help with: git counts an authenticated request # against the account rather than against the runner's shared IP. It is passed through the # environment rather than the command line so it stays out of the process list, and a token the @@ -232,10 +258,11 @@ resolve() { case "${1:-}" in detect) detect "${2:-$PWD}" ;; + checksum) checksum "${2:-$PWD}" "${3:-}" "${4:-}" ;; pick) pick "${2:-}" ;; resolve) resolve "${2:-}" ;; *) - echo "usage: version.sh detect | pick | resolve " >&2 + echo "usage: version.sh detect | checksum | pick | resolve " >&2 exit 2 ;; esac diff --git a/test/fixtures/extra-and-lockfile/composer.json b/test/fixtures/extra-and-lockfile/composer.json new file mode 100644 index 0000000..17915df --- /dev/null +++ b/test/fixtures/extra-and-lockfile/composer.json @@ -0,0 +1,8 @@ +{ + "extra": { + "mago-version": "1.29.0" + }, + "require-dev": { + "carthage-software/mago": "^1.28" + } +} diff --git a/test/fixtures/extra-and-lockfile/composer.lock b/test/fixtures/extra-and-lockfile/composer.lock new file mode 100644 index 0000000..06a8f8c --- /dev/null +++ b/test/fixtures/extra-and-lockfile/composer.lock @@ -0,0 +1,9 @@ +{ + "packages": [], + "packages-dev": [ + { + "name": "carthage-software/mago", + "version": "1.28.0" + } + ] +} diff --git a/test/fixtures/extra-map/composer.json b/test/fixtures/extra-map/composer.json new file mode 100644 index 0000000..b528d1d --- /dev/null +++ b/test/fixtures/extra-map/composer.json @@ -0,0 +1,9 @@ +{ + "extra": { + "mago-version": "1.29.0", + "mago-sha256": { + "x86_64-unknown-linux-musl": "5e99d1232fa93e6adc6feaaddaf2b46c148b2990173cdcf18400b474646bf046", + "aarch64-apple-darwin": "0000000000000000000000000000000000000000000000000000000000000000" + } + } +} diff --git a/test/fixtures/extra-wrong/composer.json b/test/fixtures/extra-wrong/composer.json new file mode 100644 index 0000000..b35eead --- /dev/null +++ b/test/fixtures/extra-wrong/composer.json @@ -0,0 +1,6 @@ +{ + "extra": { + "mago-version": "1.29.0", + "mago-sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/test/fixtures/extra/composer.json b/test/fixtures/extra/composer.json new file mode 100644 index 0000000..c2886a6 --- /dev/null +++ b/test/fixtures/extra/composer.json @@ -0,0 +1,6 @@ +{ + "extra": { + "mago-version": "1.29.0", + "mago-sha256": "5e99d1232fa93e6adc6feaaddaf2b46c148b2990173cdcf18400b474646bf046" + } +} diff --git a/test/version-test.sh b/test/version-test.sh index 02d8fe8..181b96f 100755 --- a/test/version-test.sh +++ b/test/version-test.sh @@ -67,6 +67,8 @@ detects() { fi } +detects "a version stated in extra" extra "1.29.0" +detects "extra wins over the lockfile" extra-and-lockfile "1.29.0" detects "lockfile wins over the manifest" lockfile "1.29.0" detects "manifest range when there is no lockfile" manifest "^1.29" detects "manifest exact version" manifest-exact "1.29.0" @@ -74,6 +76,30 @@ detects "mago in require rather than require-dev" manifest-require "1.29.0" detects "a project that does not use mago" no-mago "" detects "a directory with no composer files" ../.. "" +# A checksum belongs to one archive, so it is only offered for the version stated beside it and +# for the platform it was written for. +linux=x86_64-unknown-linux-musl +sha=5e99d1232fa93e6adc6feaaddaf2b46c148b2990173cdcf18400b474646bf046 +zeros=0000000000000000000000000000000000000000000000000000000000000000 + +states() { + got=$("$version_sh" checksum "$root/test/fixtures/$2" "$3" "$4") + if [ "$got" = "$5" ]; then + echo "ok checksum $2 $3 $4 -> ${5:-}" + else + echo "NOT OK $1: checksum $2 $3 $4 gave '${got:-}', wanted '${5:-}'" + failures=$((failures + 1)) + fi +} + +states "a checksum stated as one value" extra "$linux" 1.29.0 "$sha" +states "not for a version nobody stated" extra "$linux" 1.30.0 "" +states "a checksum stated per platform" extra-map "$linux" 1.29.0 "$sha" +states "the entry for the other platform" extra-map aarch64-apple-darwin 1.29.0 "$zeros" +states "no entry for this platform" extra-map aarch64-pc-windows-msvc 1.29.0 "" +states "a project that states no checksum" extra-and-lockfile "$linux" 1.29.0 "" +states "a project that states nothing" no-mago "$linux" 1.29.0 "" + if [ "$failures" -gt 0 ]; then echo "$failures check(s) failed" exit 1