Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ./
Expand Down
42 changes: 37 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 36 additions & 18 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -26,15 +27,19 @@ 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: ""

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
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
33 changes: 30 additions & 3 deletions bin/version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
# of the repository's hourly quota; asking the API to list releases is what this action avoids.
#
# version.sh detect <dir> print the mago constraint the composer files ask for, if any
# version.sh checksum <dir> <triple> <version>
# print the sha256 the composer files state for that archive, if any
# version.sh pick <spec> print the newest version on stdin that satisfies <spec>
# version.sh resolve <spec> print the concrete version <spec> means, consulting the network
#
Expand Down Expand Up @@ -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' \
Expand All @@ -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
Expand Down Expand Up @@ -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 <dir> | pick <spec> | resolve <spec>" >&2
echo "usage: version.sh detect <dir> | checksum <dir> <triple> <version> | pick <spec> | resolve <spec>" >&2
exit 2
;;
esac
8 changes: 8 additions & 0 deletions test/fixtures/extra-and-lockfile/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extra": {
"mago-version": "1.29.0"
},
"require-dev": {
"carthage-software/mago": "^1.28"
}
}
9 changes: 9 additions & 0 deletions test/fixtures/extra-and-lockfile/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions test/fixtures/extra-map/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extra": {
"mago-version": "1.29.0",
"mago-sha256": {
"x86_64-unknown-linux-musl": "5e99d1232fa93e6adc6feaaddaf2b46c148b2990173cdcf18400b474646bf046",
"aarch64-apple-darwin": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
}
6 changes: 6 additions & 0 deletions test/fixtures/extra-wrong/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extra": {
"mago-version": "1.29.0",
"mago-sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
6 changes: 6 additions & 0 deletions test/fixtures/extra/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extra": {
"mago-version": "1.29.0",
"mago-sha256": "5e99d1232fa93e6adc6feaaddaf2b46c148b2990173cdcf18400b474646bf046"
}
}
26 changes: 26 additions & 0 deletions test/version-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,39 @@ 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"
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:-<none>}"
else
echo "NOT OK $1: checksum $2 $3 $4 gave '${got:-<none>}', wanted '${5:-<none>}'"
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
Expand Down
Loading