├── .credo.exs ├── .formatter.exs ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── coveralls.json ├── guides └── images │ ├── logo.svg │ └── your_logo_here.png ├── lib ├── octo_fetch.ex └── octo_fetch │ ├── downloader.ex │ └── test.ex ├── mix.exs ├── mix.lock └── test ├── octo_fetch_test.exs └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, 86 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 87 | # You can also customize the exit_status of each check. 88 | # If you don't want TODO comments to cause `mix credo` to fail, just 89 | # set this value to 0 (zero). 90 | # 91 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 92 | {Credo.Check.Design.TagFIXME, []}, 93 | 94 | # 95 | ## Readability Checks 96 | # 97 | {Credo.Check.Readability.AliasOrder, []}, 98 | {Credo.Check.Readability.FunctionNames, []}, 99 | {Credo.Check.Readability.LargeNumbers, []}, 100 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 101 | {Credo.Check.Readability.ModuleAttributeNames, []}, 102 | {Credo.Check.Readability.ModuleDoc, []}, 103 | {Credo.Check.Readability.ModuleNames, []}, 104 | {Credo.Check.Readability.ParenthesesInCondition, []}, 105 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 106 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 107 | {Credo.Check.Readability.PredicateFunctionNames, []}, 108 | {Credo.Check.Readability.PreferImplicitTry, []}, 109 | {Credo.Check.Readability.RedundantBlankLines, []}, 110 | {Credo.Check.Readability.Semicolons, []}, 111 | {Credo.Check.Readability.SpaceAfterCommas, []}, 112 | {Credo.Check.Readability.StringSigils, []}, 113 | {Credo.Check.Readability.TrailingBlankLine, []}, 114 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 115 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 116 | {Credo.Check.Readability.VariableNames, []}, 117 | {Credo.Check.Readability.WithSingleClause, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.Apply, []}, 123 | {Credo.Check.Refactor.CondStatements, []}, 124 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 125 | {Credo.Check.Refactor.FunctionArity, []}, 126 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 127 | {Credo.Check.Refactor.MatchInCondition, []}, 128 | {Credo.Check.Refactor.MapJoin, []}, 129 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 130 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 131 | {Credo.Check.Refactor.Nesting, []}, 132 | {Credo.Check.Refactor.UnlessWithElse, []}, 133 | {Credo.Check.Refactor.WithClauses, []}, 134 | {Credo.Check.Refactor.FilterFilter, []}, 135 | {Credo.Check.Refactor.RejectReject, []}, 136 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 137 | 138 | # 139 | ## Warnings 140 | # 141 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 142 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 143 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 144 | {Credo.Check.Warning.IExPry, []}, 145 | {Credo.Check.Warning.IoInspect, []}, 146 | {Credo.Check.Warning.OperationOnSameValues, []}, 147 | {Credo.Check.Warning.OperationWithConstantResult, []}, 148 | {Credo.Check.Warning.RaiseInsideRescue, []}, 149 | {Credo.Check.Warning.SpecWithStruct, []}, 150 | {Credo.Check.Warning.WrongTestFileExtension, []}, 151 | {Credo.Check.Warning.UnusedEnumOperation, []}, 152 | {Credo.Check.Warning.UnusedFileOperation, []}, 153 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 154 | {Credo.Check.Warning.UnusedListOperation, []}, 155 | {Credo.Check.Warning.UnusedPathOperation, []}, 156 | {Credo.Check.Warning.UnusedRegexOperation, []}, 157 | {Credo.Check.Warning.UnusedStringOperation, []}, 158 | {Credo.Check.Warning.UnusedTupleOperation, []}, 159 | {Credo.Check.Warning.UnsafeExec, []} 160 | ], 161 | disabled: [ 162 | # 163 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 164 | 165 | # 166 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 167 | # and be sure to use `mix credo --strict` to see low priority checks) 168 | # 169 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 170 | {Credo.Check.Consistency.UnusedVariableNames, []}, 171 | {Credo.Check.Design.DuplicatedCode, []}, 172 | {Credo.Check.Design.SkipTestWithoutComment, []}, 173 | {Credo.Check.Readability.AliasAs, []}, 174 | {Credo.Check.Readability.BlockPipe, []}, 175 | {Credo.Check.Readability.ImplTrue, []}, 176 | {Credo.Check.Readability.MultiAlias, []}, 177 | {Credo.Check.Readability.NestedFunctionCalls, []}, 178 | {Credo.Check.Readability.SeparateAliasRequire, []}, 179 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 180 | {Credo.Check.Readability.SinglePipe, []}, 181 | {Credo.Check.Readability.Specs, []}, 182 | {Credo.Check.Readability.StrictModuleLayout, []}, 183 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 184 | {Credo.Check.Refactor.ABCSize, []}, 185 | {Credo.Check.Refactor.AppendSingleItem, []}, 186 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 187 | {Credo.Check.Refactor.FilterReject, []}, 188 | {Credo.Check.Refactor.IoPuts, []}, 189 | {Credo.Check.Refactor.MapMap, []}, 190 | {Credo.Check.Refactor.ModuleDependencies, []}, 191 | {Credo.Check.Refactor.NegatedIsNil, []}, 192 | {Credo.Check.Refactor.PipeChainStart, []}, 193 | {Credo.Check.Refactor.RejectFilter, []}, 194 | {Credo.Check.Refactor.VariableRebinding, []}, 195 | {Credo.Check.Warning.LazyLogging, []}, 196 | {Credo.Check.Warning.LeakyEnvironment, []}, 197 | {Credo.Check.Warning.MapGetUnsafePass, []}, 198 | {Credo.Check.Warning.MixEnv, []}, 199 | {Credo.Check.Warning.UnsafeToAtom, []} 200 | 201 | # {Credo.Check.Refactor.MapInto, []}, 202 | 203 | # 204 | # Custom checks can be created using `mix credo.gen.check`. 205 | # 206 | ] 207 | } 208 | } 209 | ] 210 | } 211 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | line_length: 120, 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoutmos/octo_fetch/85c2cbb04423fe5edbdfae1deb257524c8de5dfa/.github/FUNDING.yml -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help me fix bugs and problems 4 | title: '[BUG]' 5 | labels: bug 6 | assignees: akoutmos 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior or a GitHub repo that reproduces the issue. 14 | 15 | **Expected behavior** 16 | A clear and concise description of what you expected to happen. 17 | 18 | **Environment** 19 | 20 | - Elixir version: 21 | - Erlang/OTP version: 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE]' 5 | labels: feature request 6 | assignees: akoutmos 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. 11 | 12 | **Describe the solution you would like to see** 13 | A clear and concise description of what you want to happen. 14 | 15 | **How would you expect this feature to work** 16 | A description of a possible API, behaviour, modules, etc. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoutmos/octo_fetch/85c2cbb04423fe5edbdfae1deb257524c8de5dfa/.github/PULL_REQUEST_TEMPLATE.md -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: OctoFetch CI 2 | 3 | env: 4 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | static_analysis: 14 | name: Static Analysis 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v2 20 | - name: Set up Elixir 21 | uses: erlef/setup-beam@v1 22 | with: 23 | elixir-version: '1.14.0' 24 | otp-version: '25.0' 25 | - name: Restore cache 26 | uses: actions/cache@v3 27 | with: 28 | path: | 29 | deps 30 | _build 31 | priv/plts 32 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 33 | restore-keys: | 34 | ${{ runner.os }}-mix- 35 | - name: Install dependencies 36 | run: mix deps.get 37 | - name: Mix Formatter 38 | run: mix format --check-formatted 39 | - name: Check for compiler warnings 40 | run: mix compile --warnings-as-errors 41 | - name: Credo strict checks 42 | run: mix credo --strict 43 | - name: Dialyzer checks 44 | run: mix dialyzer 45 | 46 | unit_test: 47 | name: Run ExUnit tests 48 | runs-on: ubuntu-latest 49 | 50 | strategy: 51 | matrix: 52 | elixir: 53 | - '1.14' 54 | otp: 55 | - '25.0' 56 | 57 | steps: 58 | - name: Checkout code 59 | uses: actions/checkout@v2 60 | - name: Set up Elixir 61 | uses: erlef/setup-beam@v1 62 | with: 63 | elixir-version: ${{ matrix.elixir }} 64 | otp-version: ${{ matrix.otp }} 65 | - name: Install dependencies 66 | run: mix deps.get 67 | - name: ExUnit tests 68 | env: 69 | MIX_ENV: test 70 | run: mix coveralls.github 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Dialyzer PLT files 14 | /priv/plts/ 15 | 16 | # Ignore .fetch files in case you like to edit your project deps locally. 17 | /.fetch 18 | 19 | # If the VM crashes, it generates a dump, let's ignore it too. 20 | erl_crash.dump 21 | 22 | # Also ignore archive artifacts (built via "mix archive.build"). 23 | *.ez 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | octo_fetch-*.tar 27 | 28 | # Temporary files, for example, from tests. 29 | /tmp/ 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.4.0] - 2023-11-14 9 | 10 | ### Fixed 11 | 12 | - Fixed issue when extracting Zip files with directories [#2](https://github.com/akoutmos/octo_fetch/pull/2) 13 | 14 | ## [0.3.0] - 2023-03-30 15 | 16 | ### Changed 17 | 18 | - Relaxed the `:castore` version requirement 19 | 20 | ## [0.2.0] - 2022-10-08 21 | 22 | ### Added 23 | 24 | - Support for FreeBSD 25 | - `post_write_hook` callback that is invoked whenever a file is written 26 | - `pre_download_hook` callback that is invoked prior to starting a download 27 | 28 | ### Changed 29 | 30 | - Switched from `:macos` to `:darwin` 31 | 32 | ## [0.1.0] - 2022-10-08 33 | 34 | ### Added 35 | 36 | - Initial release 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexander Koutmos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | OctoFetch Logo 4 |

5 | 6 |

7 | Download, verify, and extract GitHub release artifacts effortlessly right from Elixir 8 |

9 | 10 |

11 | 12 | Hex.pm 13 | 14 | 15 | 16 | GitHub Workflow Status (master) 18 | 19 | 20 | 21 | Coveralls master branch 22 | 23 | 24 | 25 | Support OctoFetch 26 | 27 |

28 | 29 |
30 | 31 | 32 | # Contents 33 | 34 | - [Installation](#installation) 35 | - [Supporting OctoFetch](#supporting-octofetch) 36 | - [Setting Up OctoFetch](#setting-up-octofetch) 37 | - [Attribution](#attribution) 38 | 39 | ## Installation 40 | 41 | [Available in Hex](https://hex.pm/packages/octo_fetch), the package can be installed by adding 42 | `octo_fetch` to your list of dependencies in `mix.exs`: 43 | 44 | ```elixir 45 | def deps do 46 | [ 47 | {:octo_fetch, "~> 0.4.0"} 48 | ] 49 | end 50 | ``` 51 | 52 | Documentation can be found at [https://hexdocs.pm/octo_fetch](https://hexdocs.pm/octo_fetch). 53 | 54 | ## Supporting OctoFetch 55 | 56 | If you rely on this library, it would much appreciated if you can give back to the project in order to help 57 | ensure its continued development. 58 | 59 | ### Gold Sponsors 60 | 61 | 62 | Support the project 63 | 64 | 65 | ### Silver Sponsors 66 | 67 | 68 | Support the project 69 | 70 | 71 | ### Bronze Sponsors 72 | 73 | 74 | Support the project 75 | 76 | 77 | ## Setting Up OctoFetch 78 | 79 | If you want to create a downloader utility for a particular GitHub repository, you can use this library 80 | to take care of all of the boilerplate, validation, and archive extraction. For example, if you want to create 81 | a downloader for Litestream, you can do the following: 82 | 83 | ```elixir 84 | defmodule LiteStream.Downloader do 85 | use OctoFetch, 86 | latest_version: "0.3.9", 87 | github_repo: "benbjohnson/litestream", 88 | download_versions: %{ 89 | "0.3.9" => [ 90 | {:darwin, :amd64, "74599a34dc440c19544f533be2ef14cd4378ec1969b9b4fcfd24158946541869"}, 91 | {:linux, :amd64, "806e1cca4a2a105a36f219a4c212a220569d50a8f13f45f38ebe49e6699ab99f"}, 92 | {:linux, :arm64, "61acea9d960633f6df514972688c47fa26979fbdb5b4e81ebc42f4904394c5c5"} 93 | ], 94 | "0.3.8" => [ 95 | {:darwin, :amd64, "d359a4edd1cb98f59a1a7c787bbd0ed30c6cc3126b02deb05a0ca501ff94a46a"}, 96 | {:linux, :amd64, "530723d95a51ee180e29b8eba9fee8ddafc80a01cab7965290fb6d6fc31381b3"}, 97 | {:linux, :arm64, "1d6fb542c65b7b8bf91c8859d99f2f48b0b3251cc201341281f8f2c686dd81e2"} 98 | ] 99 | } 100 | 101 | # You must implement this function to generate the names of the downloads based on the 102 | # user's current running environment 103 | @impl true 104 | def download_name(version, :darwin, arch), do: "litestream-v\#{version}-darwin-\#{arch}.zip" 105 | def download_name(version, :linux, arch), do: "litestream-v\#{version}-linux-\#{arch}.tar.gz" 106 | end 107 | ``` 108 | 109 | You would then be able to download the release artifact by doing the following: 110 | 111 | ```elixir 112 | iex (1) > Litestream.Downloader.download(".") 113 | {:ok, ["./litestream"], []} 114 | ``` 115 | 116 | ## Attribution 117 | 118 | It wouldn't be right to not include somewhere in this project a "thank you" to the various projects and people that 119 | helped make this possible: 120 | 121 | - The logo for the project is an edited version of an SVG image from the [unDraw project](https://undraw.co/) 122 | - The work done in [Phoenix Tailwind](https://github.com/phoenixframework/tailwind) served as a baseline for how to 123 | structure this library. 124 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() != :prod do 4 | config :git_hooks, 5 | auto_install: true, 6 | verbose: true, 7 | hooks: [ 8 | pre_commit: [ 9 | tasks: [ 10 | {:cmd, "mix format --check-formatted"}, 11 | {:cmd, "mix compile --warnings-as-errors"}, 12 | {:cmd, "mix credo --strict"}, 13 | {:cmd, "mix dialyzer"}, 14 | {:cmd, "mix test"} 15 | ] 16 | ] 17 | ] 18 | end 19 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": ["test/"], 3 | "minimum_coverage": 75 4 | } 5 | -------------------------------------------------------------------------------- /guides/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /guides/images/your_logo_here.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akoutmos/octo_fetch/85c2cbb04423fe5edbdfae1deb257524c8de5dfa/guides/images/your_logo_here.png -------------------------------------------------------------------------------- /lib/octo_fetch.ex: -------------------------------------------------------------------------------- 1 | defmodule OctoFetch do 2 | @moduledoc """ 3 | This library allows you to download release artifacts from GitHub. By using this library 4 | you get the following functionality for your GitHub downloader client: 5 | 6 | - Automatic SHA validation for downloaded artifacts (security measure to ensure that 7 | users download valid copies of artifacts) 8 | - Automatic archive extraction for `.zip` and `.tar.gz` downloads 9 | - Dynamic artifact downloading based on user's platform 10 | - Support only specific versions of artifacts from the provided repo 11 | 12 | ## Sample usage 13 | 14 | If you want to make a downloader for the Litestream binary for example, you can define 15 | a downloader module like so: 16 | 17 | ```elixir 18 | defmodule LiteStream.Downloader do 19 | use OctoFetch, 20 | latest_version: "0.3.9", 21 | github_repo: "benbjohnson/litestream", 22 | download_versions: %{ 23 | "0.3.9" => [ 24 | {:darwin, :amd64, "74599a34dc440c19544f533be2ef14cd4378ec1969b9b4fcfd24158946541869"}, 25 | {:linux, :amd64, "806e1cca4a2a105a36f219a4c212a220569d50a8f13f45f38ebe49e6699ab99f"}, 26 | {:linux, :arm64, "61acea9d960633f6df514972688c47fa26979fbdb5b4e81ebc42f4904394c5c5"} 27 | ], 28 | "0.3.8" => [ 29 | {:darwin, :amd64, "d359a4edd1cb98f59a1a7c787bbd0ed30c6cc3126b02deb05a0ca501ff94a46a"}, 30 | {:linux, :amd64, "530723d95a51ee180e29b8eba9fee8ddafc80a01cab7965290fb6d6fc31381b3"}, 31 | {:linux, :arm64, "1d6fb542c65b7b8bf91c8859d99f2f48b0b3251cc201341281f8f2c686dd81e2"} 32 | ] 33 | } 34 | 35 | # You must implement this function to generate the names of the downloads based on the 36 | # user's current running environment 37 | @impl true 38 | def download_name(version, :darwin, arch), do: "litestream-v\#{version}-darwin-\#{arch}.zip" 39 | def download_name(version, :linux, arch), do: "litestream-v\#{version}-linux-\#{arch}.tar.gz" 40 | end 41 | ``` 42 | 43 | You would then be able to download the release artifact by doing the following: 44 | 45 | ```elixir 46 | Litestream.Downloader.download(".") 47 | ``` 48 | 49 | If you are on an ARM based Mac, the above snippet won't work since Litestream does not currently 50 | build ARM artifacts. But you can always override what OctoFetch dynamically resolves 51 | by doing the following: 52 | 53 | ```elixir 54 | Litestream.Downloader.download(".", override_architecture: :amd64) 55 | ``` 56 | 57 | Be sure to look at the `OctoFetch.download/3` docs for supported options and 58 | `OctoFetch.Downloader` to see what behaviour callbacks you can override. 59 | """ 60 | 61 | require Logger 62 | 63 | defmacro __using__(opts) do 64 | # Validated that the required options are provided 65 | [:latest_version, :github_repo, :download_versions] 66 | |> Enum.each(fn key -> 67 | unless Keyword.has_key?(opts, key) do 68 | raise "#{key} is a required option when calling `use OctoFetch`" 69 | end 70 | end) 71 | 72 | latest_version = Keyword.fetch!(opts, :latest_version) 73 | 74 | quote do 75 | @behaviour OctoFetch.Downloader 76 | 77 | @impl true 78 | def base_url(github_repo, version) do 79 | "https://github.com/#{github_repo}/releases/download/v#{version}/" 80 | end 81 | 82 | @impl true 83 | def default_version do 84 | unquote(latest_version) 85 | end 86 | 87 | @impl true 88 | def download_name(_, _, _) do 89 | raise "#{__MODULE__} must implement the download_name/3 callback" 90 | end 91 | 92 | @impl true 93 | def post_write_hook(_) do 94 | :ok 95 | end 96 | 97 | @impl true 98 | def pre_download_hook(_, _) do 99 | :cont 100 | end 101 | 102 | @impl true 103 | def download(output_dir, opts \\ []) do 104 | opts = Keyword.merge(unquote(opts), opts) 105 | OctoFetch.download(__MODULE__, output_dir, opts) 106 | end 107 | 108 | @doc false 109 | def init_opts do 110 | unquote(opts) 111 | end 112 | 113 | defoverridable OctoFetch.Downloader 114 | end 115 | end 116 | 117 | @doc """ 118 | Download the GitHub release artifact and write it to the specified location. 119 | 120 | The supported `opts` arguments are: 121 | 122 | - `override_version`: By default, the latest version (as specified by the downloader module) will 123 | be downloaded. But you can also specify any additional versions that are also supported by the 124 | `:download_versions` map. 125 | 126 | - `override_operating_system`: By default, the operating system is dynamically deteremined based on 127 | the what the BEAM reports. If you would like to override those results, you can pass 128 | `:windows`, `:darwin`, or `:linux`. 129 | 130 | - `override_architecture`: By default, the architecture is dynamically deteremined based on 131 | the what the BEAM reports. If you would like to override those results, you can pass `:amd64` 132 | or `:arm64`. 133 | """ 134 | @spec download(downloader_module :: module(), output_dir :: String.t(), opts :: Keyword.t()) :: 135 | OctoFetch.Downloader.download_result() 136 | def download(downloader_module, output_dir, opts) do 137 | version_download_matrix = Keyword.fetch!(opts, :download_versions) 138 | github_repo = Keyword.fetch!(opts, :github_repo) 139 | 140 | version = 141 | Keyword.get_lazy(opts, :override_version, fn -> 142 | downloader_module.default_version() 143 | end) 144 | 145 | with :ok <- check_output_dir(output_dir), 146 | {:ok, version_downloads} <- check_requested_version(version, version_download_matrix), 147 | {:ok, operating_system} <- get_platform_os(opts), 148 | {:ok, architecture} <- get_platform_architecture(opts), 149 | {:ok, sha_checksum} <- 150 | get_sha_for_platform(operating_system, architecture, version_downloads), 151 | {:ok, github_base_url} <- 152 | generate_github_base_url(downloader_module, github_repo, version), 153 | {:ok, artifact_name} <- 154 | generate_artifact_name(downloader_module, version, operating_system, architecture), 155 | full_download_url <- Path.join(github_base_url, artifact_name), 156 | :cont <- downloader_module.pre_download_hook(artifact_name, output_dir), 157 | {:ok, artifact_contents} <- download_artifact(full_download_url, opts), 158 | :ok <- verify_artifact_checksum(artifact_contents, sha_checksum, full_download_url), 159 | {:ok, files_to_write} <- 160 | maybe_extract_artifact_contents(artifact_contents, artifact_name) do 161 | {successful_files, failed_files} = 162 | files_to_write 163 | |> Enum.reduce({[], []}, fn {file_name, file_contents}, {successful_acc, failed_acc} -> 164 | file_write_path = Path.join(output_dir, file_name) 165 | file_write_dir = Path.dirname(file_write_path) 166 | 167 | with {:make_directory, :ok} <- {:make_directory, File.mkdir_p(file_write_dir)}, 168 | {:write_file, :ok} <- {:write_file, File.write(file_write_path, file_contents)} do 169 | downloader_module.post_write_hook(file_write_path) 170 | {[file_write_path | successful_acc], failed_acc} 171 | else 172 | {:make_directory, error} -> 173 | Logger.warning("Failed to create directory for #{file_write_path}: #{inspect(error)}") 174 | {successful_acc, [file_write_path | failed_acc]} 175 | 176 | {:write_file, error} -> 177 | Logger.warning("Failed to extract #{file_write_path}: #{inspect(error)}") 178 | {successful_acc, [file_write_path | failed_acc]} 179 | end 180 | end) 181 | 182 | {:ok, successful_files, failed_files} 183 | else 184 | {:error, reason} -> 185 | Logger.warning("Failed to download release from GitHub. #{reason}") 186 | {:error, reason} 187 | 188 | :skip -> 189 | :skip 190 | end 191 | end 192 | 193 | defp check_output_dir(output_dir) do 194 | if File.exists?(output_dir) do 195 | :ok 196 | else 197 | {:error, "Output directory #{output_dir} does not exist"} 198 | end 199 | end 200 | 201 | defp maybe_extract_artifact_contents(artifact_contents, artifact_name) do 202 | cond do 203 | String.ends_with?(artifact_name, ".zip") -> 204 | unzip_download(artifact_contents) 205 | 206 | String.ends_with?(artifact_name, ".tar.gz") -> 207 | untar_download(artifact_contents) 208 | 209 | true -> 210 | {:ok, [{artifact_name, artifact_contents}]} 211 | end 212 | end 213 | 214 | defp unzip_download(artifact_contents) do 215 | :zip.extract(artifact_contents, [:memory]) 216 | end 217 | 218 | defp untar_download(artifact_contents) do 219 | :erl_tar.extract({:binary, artifact_contents}, [:memory, :compressed]) 220 | end 221 | 222 | defp download_artifact(download_url, opts) do 223 | github_repo = Keyword.fetch!(opts, :github_repo) 224 | 225 | Logger.info("Downloading #{github_repo} from #{download_url}") 226 | 227 | # Ensure that the necessary applications have been started 228 | {:ok, _} = Application.ensure_all_started(:inets) 229 | {:ok, _} = Application.ensure_all_started(:ssl) 230 | 231 | if proxy = System.get_env("HTTP_PROXY") || System.get_env("http_proxy") do 232 | Logger.debug("Using HTTP_PROXY: #{proxy}") 233 | %{host: host, port: port} = URI.parse(proxy) 234 | :httpc.set_options([{:proxy, {{String.to_charlist(host), port}, []}}]) 235 | end 236 | 237 | if proxy = System.get_env("HTTPS_PROXY") || System.get_env("https_proxy") do 238 | Logger.debug("Using HTTPS_PROXY: #{proxy}") 239 | %{host: host, port: port} = URI.parse(proxy) 240 | :httpc.set_options([{:https_proxy, {{String.to_charlist(host), port}, []}}]) 241 | end 242 | 243 | # https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/inets 244 | cacertfile = CAStore.file_path() |> String.to_charlist() 245 | 246 | http_options = [ 247 | ssl: [ 248 | verify: :verify_peer, 249 | cacertfile: cacertfile, 250 | depth: 2, 251 | customize_hostname_check: [ 252 | match_fun: :public_key.pkix_verify_hostname_match_fun(:https) 253 | ], 254 | versions: protocol_versions() 255 | ] 256 | ] 257 | 258 | options = [body_format: :binary] 259 | 260 | case :httpc.request(:get, {download_url, []}, http_options, options) do 261 | {:ok, {{_, 200, _}, _headers, body}} -> 262 | {:ok, body} 263 | 264 | error -> 265 | {:error, "Failed to download #{github_repo} from #{download_url}: #{inspect(error)}"} 266 | end 267 | end 268 | 269 | defp verify_artifact_checksum(artifact_contents, known_sha_checksum, download_url) do 270 | computed_sha = 271 | :sha256 272 | |> :crypto.hash(artifact_contents) 273 | |> Base.encode16() 274 | |> String.downcase() 275 | 276 | if known_sha_checksum == computed_sha do 277 | :ok 278 | else 279 | {:error, "Invalid SHA256 value computed for #{download_url}"} 280 | end 281 | end 282 | 283 | defp protocol_versions do 284 | if otp_version() < 25, do: [:"tlsv1.2"], else: [:"tlsv1.2", :"tlsv1.3"] 285 | end 286 | 287 | defp otp_version do 288 | :erlang.system_info(:otp_release) |> List.to_integer() 289 | end 290 | 291 | defp get_sha_for_platform(operating_system, architecture, version_downloads) do 292 | version_downloads 293 | |> Enum.find_value(fn {os_entry, arch_entry, sha} -> 294 | if os_entry == operating_system and arch_entry == architecture do 295 | sha 296 | end 297 | end) 298 | |> case do 299 | nil -> 300 | {:error, 301 | "Your platform is not supported for the provided version (os=#{operating_system}, architecture=#{architecture})"} 302 | 303 | sha -> 304 | {:ok, sha} 305 | end 306 | end 307 | 308 | defp check_requested_version(requested_version, supported_versions) do 309 | case Map.fetch(supported_versions, requested_version) do 310 | {:ok, sha_checksums} -> {:ok, sha_checksums} 311 | _ -> {:error, "#{requested_version} is not a supported version"} 312 | end 313 | end 314 | 315 | defp generate_github_base_url(downloader_module, github_repo, version) do 316 | {:ok, downloader_module.base_url(github_repo, version)} 317 | end 318 | 319 | defp get_platform_os(opts) do 320 | opts 321 | |> Keyword.get_lazy(:override_operating_system, fn -> 322 | case :os.type() do 323 | {:win32, _} -> 324 | :windows 325 | 326 | {:unix, :darwin} -> 327 | :darwin 328 | 329 | {:unix, :linux} -> 330 | :linux 331 | 332 | {:unix, :freebsd} -> 333 | :freebsd 334 | 335 | unknown_os -> 336 | {:error, 337 | "Open up an issue at https://github.com/akoutmos/octo_fetch as OS could not be derived for: os=#{inspect(unknown_os)}"} 338 | end 339 | end) 340 | |> case do 341 | {:error, error} -> {:error, error} 342 | os -> {:ok, os} 343 | end 344 | end 345 | 346 | defp get_platform_architecture(opts) do 347 | opts 348 | |> Keyword.get_lazy(:override_architecture, fn -> 349 | arch_str = :erlang.system_info(:system_architecture) 350 | [arch | _] = arch_str |> List.to_string() |> String.split("-") 351 | 352 | case {:os.type(), arch, :erlang.system_info(:wordsize) * 8} do 353 | {{:win32, _}, _arch, 64} -> 354 | :amd64 355 | 356 | {_os, arch, 64} when arch in ~w(arm aarch64) -> 357 | :arm64 358 | 359 | {_os, arch, 64} when arch in ~w(amd64 x86_64) -> 360 | :amd64 361 | 362 | {os, arch, _wordsize} -> 363 | {:error, 364 | "Open up an issue at https://github.com/akoutmos/octo_fetch as architecture could not be derived for: os=#{inspect(os)}, arch=#{inspect(arch)}"} 365 | end 366 | end) 367 | |> case do 368 | {:error, error} -> {:error, error} 369 | arch -> {:ok, arch} 370 | end 371 | end 372 | 373 | defp generate_artifact_name(downloader_module, version, operating_system, architecture) do 374 | {:ok, downloader_module.download_name(version, operating_system, architecture)} 375 | end 376 | end 377 | -------------------------------------------------------------------------------- /lib/octo_fetch/downloader.ex: -------------------------------------------------------------------------------- 1 | defmodule OctoFetch.Downloader do 2 | @moduledoc """ 3 | This module defines the callbacks that a GitHub downloader needs 4 | to implement in order to fetch artifacts from GitHub. `base_url/2` and 5 | `default_version/0` are automatically implemented for you when you use 6 | the `OctoFetch` module, but you always have the option to 7 | override their default implementations. 8 | """ 9 | 10 | @type os() :: :linux | :darwin | :freebsd | :windows 11 | @type arch() :: :arm64 | :amd64 12 | @type download_result() :: {:ok, successful_files :: list(), failed_files :: list()} | {:error, String.t()} | :skip 13 | 14 | @doc """ 15 | This callback generates the base URL for the artifact based on the provided GitHub repo 16 | and the requested version. The default implementation from `OctoFetch` is: 17 | 18 | ```elixir 19 | def base_url(github_repo, version) do 20 | "https://github.com/\#{github_repo}/releases/download/v\#{version}/" 21 | end 22 | ``` 23 | """ 24 | @callback base_url(github_repo :: String.t(), version :: String.t()) :: String.t() 25 | 26 | @doc """ 27 | This callback returns the default version that sould be downloaded if the 28 | user does not override the version. It will default to the value of `:latest_version` 29 | as provided to the `OctoFetch` `__using__/1` macro. 30 | """ 31 | @callback default_version :: String.t() 32 | 33 | @doc """ 34 | This function must be implemented by your downloader module and is used to 35 | dynamically generate the name of the download artifact based on the user's 36 | running environment. For example, for Litestream you would do something like 37 | so to ensure that users download the proper artifact: 38 | 39 | ```elixir 40 | def download_name(version, :darwin, arch), do: "litestream-v\#{version}-darwin-\#{arch}.zip" 41 | def download_name(version, :linux, arch), do: "litestream-v\#{version}-linux-\#{arch}.tar.gz" 42 | ``` 43 | """ 44 | @callback download_name(version :: String.t(), operation_system :: os(), architecture :: arch()) :: 45 | String.t() 46 | 47 | @doc """ 48 | This callback is invoked whenever a file is written to the filesystem as 49 | a result from the download. This callback may be invoked several times 50 | if the download was an archive file and contained multiple files. 51 | """ 52 | @callback post_write_hook(file :: String.t()) :: :ok 53 | 54 | @doc """ 55 | This callback is invoked prior to a file being downloaded. This gives you 56 | the opportunity to skip the download if you so chose by returning `:skip`. Otherwise, 57 | return `:cont` to continue with the download. 58 | """ 59 | @callback pre_download_hook(file :: String.t(), output_dir :: String.t()) :: :cont | :skip 60 | 61 | @doc """ 62 | This callback acts as a pass through to the `OctoFetch` module for the 63 | downloader implementation. See `OctoFetch.download/3` for supported `opts`. 64 | """ 65 | @callback download(output_dir :: String.t(), opts :: Keyword.t()) :: download_result() 66 | end 67 | -------------------------------------------------------------------------------- /lib/octo_fetch/test.ex: -------------------------------------------------------------------------------- 1 | defmodule OctoFetch.Test do 2 | @moduledoc """ 3 | This module contains testing utilities to validate 4 | downloader modules and their implementations. 5 | """ 6 | 7 | import ExUnit.Assertions, only: [assert: 1] 8 | 9 | @doc """ 10 | Go through all of the supported downloads and validate that 11 | they work. 12 | """ 13 | def test_all_supported_downloads(downloader_module) do 14 | # Get all of the download versions 15 | download_versions = Keyword.fetch!(downloader_module.init_opts(), :download_versions) 16 | 17 | # Create a place to store the file downloads 18 | tmp_octo_fetch_dir = Path.join(System.tmp_dir!(), "/octo_fetch_test_downloads") 19 | File.mkdir_p!(tmp_octo_fetch_dir) 20 | 21 | try do 22 | download_versions 23 | |> Enum.each(fn {version, builds} -> 24 | Enum.each(builds, fn {os, arch, sha} -> 25 | # A dir for the output of the current build 26 | current_build_output_dir = Path.join(tmp_octo_fetch_dir, sha) 27 | File.mkdir_p!(current_build_output_dir) 28 | 29 | assert {:ok, _successful_files, _failed_files} = 30 | downloader_module.download(current_build_output_dir, 31 | override_version: version, 32 | override_operating_system: os, 33 | override_architecture: arch 34 | ) 35 | 36 | File.rm_rf!(current_build_output_dir) 37 | end) 38 | end) 39 | after 40 | # Delete all of the file downloads 41 | if File.exists?(tmp_octo_fetch_dir) do 42 | File.rm_rf!(tmp_octo_fetch_dir) 43 | end 44 | end 45 | end 46 | 47 | @doc """ 48 | Test that the specified version can be downloaded 49 | """ 50 | def test_version_for_current_platform(downloader_module, version) do 51 | # Create a place to store the file downloads 52 | tmp_octo_fetch_dir = Path.join(System.tmp_dir!(), "/octo_fetch_test_downloads") 53 | File.mkdir_p!(tmp_octo_fetch_dir) 54 | 55 | try do 56 | # A dir for the output of the current build 57 | current_build_output_dir = Path.join(tmp_octo_fetch_dir, version) 58 | File.mkdir_p!(current_build_output_dir) 59 | 60 | assert {:ok, _successful_files, _failed_files} = 61 | downloader_module.download(current_build_output_dir, override_version: version) 62 | 63 | File.rm_rf!(current_build_output_dir) 64 | after 65 | # Delete all of the file downloads 66 | if File.exists?(tmp_octo_fetch_dir) do 67 | File.rm_rf!(tmp_octo_fetch_dir) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule OctoFetch.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :octo_fetch, 7 | version: "0.4.0", 8 | elixir: "~> 1.13", 9 | name: "OctoFetch", 10 | source_url: "https://github.com/akoutmos/octo_fetch", 11 | homepage_url: "https://hex.pm/packages/octo_fetch", 12 | description: "Download, verify, and extract GitHub release artifacts effortlessly right from Elixir", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | start_permanent: Mix.env() == :prod, 15 | test_coverage: [tool: ExCoveralls], 16 | preferred_cli_env: [ 17 | coveralls: :test, 18 | "coveralls.detail": :test, 19 | "coveralls.post": :test, 20 | "coveralls.html": :test, 21 | "coveralls.github": :test 22 | ], 23 | dialyzer: [ 24 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"} 25 | ], 26 | package: package(), 27 | deps: deps(), 28 | docs: docs(), 29 | aliases: aliases() 30 | ] 31 | end 32 | 33 | # Run "mix help compile.app" to learn about applications. 34 | def application do 35 | [ 36 | extra_applications: [:logger, :inets, :public_key] 37 | ] 38 | end 39 | 40 | defp elixirc_paths(:test), do: ["lib", "test/support"] 41 | defp elixirc_paths(_), do: ["lib"] 42 | 43 | # Run "mix help deps" to learn about dependencies. 44 | defp deps do 45 | [ 46 | # Production dependencies 47 | {:castore, "~> 0.1 or ~> 1.0"}, 48 | {:ssl_verify_fun, "~> 1.1"}, 49 | 50 | # Development dependencies 51 | {:ex_doc, "~> 0.30.9", only: :dev}, 52 | {:excoveralls, "~> 0.18.0", only: :test, runtime: false}, 53 | {:credo, "~> 1.7.1", only: :dev}, 54 | {:dialyxir, "~> 1.4.2", only: :dev, runtime: false}, 55 | {:git_hooks, "~> 0.7.3", only: [:test, :dev], runtime: false} 56 | ] 57 | end 58 | 59 | defp docs do 60 | [ 61 | main: "readme", 62 | source_ref: "master", 63 | logo: "guides/images/logo.svg", 64 | extras: [ 65 | "README.md" 66 | ] 67 | ] 68 | end 69 | 70 | defp package do 71 | [ 72 | name: "octo_fetch", 73 | files: ~w(lib mix.exs README.md LICENSE CHANGELOG.md), 74 | licenses: ["MIT"], 75 | maintainers: ["Alex Koutmos"], 76 | links: %{ 77 | "GitHub" => "https://github.com/akoutmos/octo_fetch", 78 | "Sponsor" => "https://github.com/sponsors/akoutmos" 79 | } 80 | ] 81 | end 82 | 83 | defp aliases do 84 | [ 85 | docs: ["docs", ©_files/1] 86 | ] 87 | end 88 | 89 | defp copy_files(_) do 90 | # Set up directory structure 91 | File.mkdir_p!("./doc/guides/images") 92 | 93 | # Copy over image files 94 | "./guides/images/" 95 | |> File.ls!() 96 | |> Enum.each(fn image_file -> 97 | File.cp!("./guides/images/#{image_file}", "./doc/guides/images/#{image_file}") 98 | end) 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, 3 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 4 | "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, 5 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 6 | "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, 7 | "dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.38", "b42252eddf63bda05554ba8be93a1262dc0920c721f1aaf989f5de0f73a2e367", [:mix], [], "hexpm", "2cd0907795aaef0c7e8442e376633c5b3bd6edc8dbbdc539b22f095501c1cdb6"}, 9 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 10 | "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, 11 | "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, 12 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 13 | "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, 14 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 15 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 16 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 17 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 20 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 21 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 23 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 24 | "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, 25 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 26 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 27 | } 28 | -------------------------------------------------------------------------------- /test/octo_fetch_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OctoFetchTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureLog 5 | 6 | defmodule Litestream.Fetcher do 7 | use OctoFetch, 8 | latest_version: "0.3.9", 9 | github_repo: "benbjohnson/litestream", 10 | download_versions: %{ 11 | "0.3.9" => [ 12 | {:linux, :amd64, "806e1cca4a2a105a36f219a4c212a220569d50a8f13f45f38ebe49e6699ab99f"}, 13 | {:darwin, :amd64, "74599a34dc440c19544f533be2ef14cd4378ec1969b9b4fcfd24158946541869"}, 14 | {:darwin, :arm64, "74599a34dc440c19544f533be2ef14cd4378ec1969b9b4fcfd24158946541869"} 15 | ], 16 | "0.3.8" => [ 17 | {:linux, :amd64, "530723d95a51ee180e29b8eba9fee8ddafc80a01cab7965290fb6d6fc31381b3"} 18 | ] 19 | } 20 | 21 | @impl true 22 | def download_name(version, :darwin, _arch), do: "litestream-v#{version}-darwin-amd64.zip" 23 | def download_name(version, :linux, arch), do: "litestream-v#{version}-linux-#{arch}.tar.gz" 24 | end 25 | 26 | test "Should download all of the specified versions" do 27 | OctoFetch.Test.test_all_supported_downloads(Litestream.Fetcher) 28 | end 29 | 30 | test "Should download the specified version on the current platform" do 31 | OctoFetch.Test.test_version_for_current_platform(Litestream.Fetcher, "0.3.9") 32 | end 33 | 34 | test "Should return an error if an invalid version is provided" do 35 | capture_log(fn -> 36 | assert {:error, "invalid is not a supported version"} = 37 | Litestream.Fetcher.download(".", override_version: "invalid") 38 | end) =~ "invalid is not a supported version" 39 | end 40 | 41 | test "Should return an error if an invalid output directory is provided" do 42 | capture_log(fn -> 43 | assert {:error, "Output directory ./this/dir/does/not/exist does not exist"} = 44 | Litestream.Fetcher.download("./this/dir/does/not/exist") 45 | end) =~ "Output directory ./this/dir/does/not/exist does not exist" 46 | end 47 | 48 | test "Should return an error if an invalid architecture is provided" do 49 | capture_log(fn -> 50 | assert {:error, "Your platform is not supported for the provided version" <> _} = 51 | Litestream.Fetcher.download(".", override_architecture: :bad_arch) 52 | end) =~ "Your platform is not supported for the provided version" 53 | end 54 | 55 | test "Should return an error if an invalid OS is provided" do 56 | capture_log(fn -> 57 | assert {:error, "Your platform is not supported for the provided version" <> _} = 58 | Litestream.Fetcher.download(".", override_operating_system: :bad_os) 59 | end) =~ "Your platform is not supported for the provided version" 60 | end 61 | 62 | @tag :tmp_dir 63 | test "Should return an :ok tuple with the archive files", %{tmp_dir: tmp_dir} do 64 | expected_output = Path.join(tmp_dir, "litestream") 65 | assert {:ok, [^expected_output], []} = Litestream.Fetcher.download(tmp_dir) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------