├── .envrc ├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── copyright.code-snippets ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── action.yml ├── babel.config.js ├── build.js ├── dist ├── cleanup.js └── index.js ├── flake.lock ├── flake.nix ├── install-pre-commit.sh ├── package-lock.json ├── package.json ├── pre-commit.sh ├── scripts └── hash_binaries.mjs ├── src ├── cleanup.ts ├── common.ts ├── config_gen.test.ts ├── config_gen.ts └── index.ts └── tsconfig.json /.envrc: -------------------------------------------------------------------------------- 1 | #shellcheck shell=bash 2 | use flake 3 | export CLOUDSDK_CORE_PROJECT=zombiezen-nix-cache-action 4 | export CLOUDSDK_COMPONENT_MANAGER_DISABLE_UPDATE_CHECK=True 5 | 6 | # For GITHUB_TOKEN in scripts/hash_binaries.mjs. 7 | dotenv_if_exists 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # dist/ is built from src/. 2 | # 1. don't show diffs in GitHub, and 3 | # 2. don't try to merge files (they should be re-recorded) 4 | dist/*.js linguist-generated=true binary 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: zombiezen 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: daily 7 | allow: 8 | - dependency-type: production 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Ross Light 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: Test 18 | on: 19 | push: 20 | branches: 21 | - '**' 22 | pull_request: 23 | branches: 24 | - 'main' 25 | jobs: 26 | check: 27 | name: nix flake check 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: read 31 | steps: 32 | - name: Check out code 33 | uses: actions/checkout@v4 34 | - name: Install Nix 35 | uses: cachix/install-nix-action@v23 36 | with: 37 | extra_nix_config: | 38 | experimental-features = nix-command flakes ca-derivations impure-derivations 39 | - name: nix flake check 40 | run: nix flake check --print-build-logs 41 | integration: 42 | name: Integration 43 | runs-on: ubuntu-latest 44 | permissions: 45 | contents: read 46 | id-token: write 47 | steps: 48 | - name: Check out code 49 | uses: actions/checkout@v4 50 | - name: Install Nix 51 | uses: cachix/install-nix-action@v23 52 | - id: google-auth 53 | name: Authenticate to Google Cloud Platform 54 | uses: google-github-actions/auth@v1 55 | with: 56 | workload_identity_provider: ${{ vars.GOOGLE_WORKLOAD_IDENTITY_PROVIDER }} 57 | service_account: ${{ vars.GOOGLE_SERVICE_ACCOUNT }} 58 | token_format: access_token 59 | - name: Set up cache 60 | uses: ./ 61 | with: 62 | substituters: gs://zombiezen-setup-nix-cache-action 63 | secret_keys: ${{ secrets.NIX_PRIVATE_KEY }} 64 | use_nixcached: true 65 | - name: nix build 66 | run: nix build --print-build-logs '.#devShells.x86_64-linux.default' 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.tsbuildinfo 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /package.json 2 | /package-lock.json 3 | /dist/ 4 | /.vscode/ 5 | *.md 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/copyright.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Copyright": { 3 | "prefix": "copyright", 4 | "body": [ 5 | "$LINE_COMMENT Copyright $CURRENT_YEAR Ross Light", 6 | "$LINE_COMMENT", 7 | "$LINE_COMMENT Licensed under the Apache License, Version 2.0 (the \"License\");", 8 | "$LINE_COMMENT you may not use this file except in compliance with the License.", 9 | "$LINE_COMMENT You may obtain a copy of the License at", 10 | "$LINE_COMMENT", 11 | "$LINE_COMMENT https://www.apache.org/licenses/LICENSE-2.0", 12 | "$LINE_COMMENT", 13 | "$LINE_COMMENT Unless required by applicable law or agreed to in writing, software", 14 | "$LINE_COMMENT distributed under the License is distributed on an \"AS IS\" BASIS,", 15 | "$LINE_COMMENT WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.", 16 | "$LINE_COMMENT See the License for the specific language governing permissions and", 17 | "$LINE_COMMENT limitations under the License.", 18 | "$LINE_COMMENT", 19 | "$LINE_COMMENT SPDX-License-Identifier: Apache-2.0" 20 | ], 21 | "description": "Apache license header" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "dist": true 4 | }, 5 | "typescript.tsdk": "node_modules/typescript/lib", 6 | "[typescript]": { 7 | "editor.formatOnSave": true, 8 | "editor.defaultFormatter": "esbenp.prettier-vscode", 9 | }, 10 | "[javascript]": { 11 | "editor.formatOnSave": true, 12 | "editor.defaultFormatter": "esbenp.prettier-vscode", 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "npm: build", 8 | "type": "shell", 9 | "command": "direnv exec . npm run build", 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | }, 14 | "problemMatcher": [ 15 | "$tsc" 16 | ] 17 | }, 18 | { 19 | "label": "npm: test", 20 | "type": "shell", 21 | "command": "direnv exec . npm test", 22 | "group": { 23 | "kind": "test", 24 | "isDefault": true 25 | }, 26 | "problemMatcher": [], 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Set up Nix Cache Action 2 | 3 | This is a [GitHub Action][] that configures the [Nix][] package manager 4 | to read from (and optionally write to) 5 | a remote cache. 6 | 7 | [GitHub Action]: https://docs.github.com/en/actions 8 | [Nix]: https://nixos.org/ 9 | 10 | ## Usage 11 | 12 | Using an [Amazon Web Services S3][] bucket for loading and storing: 13 | 14 | ```yaml 15 | name: Build 16 | jobs: 17 | build: 18 | name: Build 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Check out code 22 | uses: actions/checkout@v4 23 | - name: Install Nix 24 | uses: cachix/install-nix-action@v23 25 | - name: Set up cache 26 | uses: zombiezen/setup-nix-cache-action@v0.4.0 27 | with: 28 | substituters: s3://example-bucket 29 | secret_keys: ${{ secrets.NIX_PRIVATE_KEY }} 30 | aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} 31 | aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 32 | - name: Build 33 | run: nix-build 34 | ``` 35 | 36 | Using a [Google Cloud Storage][] bucket for loading and storing: 37 | 38 | ```yaml 39 | name: Build 40 | jobs: 41 | build: 42 | name: Build 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Check out code 46 | uses: actions/checkout@v4 47 | - name: Install Nix 48 | uses: cachix/install-nix-action@v23 49 | - name: Authenticate to Google Cloud Platform 50 | # See https://github.com/google-github-actions/auth/blob/main/README.md 51 | # for details on how to set up. 52 | uses: google-github-actions/auth@v1 53 | - name: Set up cache 54 | uses: zombiezen/setup-nix-cache-action@v0.4.0 55 | with: 56 | substituters: gs://example-bucket 57 | secret_keys: ${{ secrets.NIX_PRIVATE_KEY }} 58 | use_nixcached: true 59 | - name: Build 60 | run: nix-build 61 | ``` 62 | 63 | The example above uses [nixcached][] to connect to Google Cloud Storage 64 | using normal service account credentials. 65 | If you prefer to avoid the dependency, you can instead use the [interoperability endpoint][], 66 | but you will have to generate an HMAC key: 67 | 68 | ```yaml 69 | # Connecting to GCS without nixcached (not recommended). 70 | 71 | name: Build 72 | jobs: 73 | build: 74 | name: Build 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Check out code 78 | uses: actions/checkout@v4 79 | - name: Install Nix 80 | uses: cachix/install-nix-action@v23 81 | - name: Set up cache 82 | uses: zombiezen/setup-nix-cache-action@v0.4.0 83 | with: 84 | substituters: s3://example-bucket?endpoint=https://storage.googleapis.com 85 | secret_keys: ${{ secrets.NIX_PRIVATE_KEY }} 86 | aws_access_key_id: ${{ secrets.GCS_HMAC_ACCESS_ID }} 87 | aws_secret_access_key: ${{ secrets.GCS_HMAC_SECRET_ACCESS_KEY }} 88 | - name: Build 89 | run: nix-build 90 | ``` 91 | 92 | [Amazon Web Services S3]: https://aws.amazon.com/s3/ 93 | [Google Cloud Storage]: https://cloud.google.com/storage 94 | [interoperability endpoint]: https://cloud.google.com/storage/docs/interoperability 95 | [nixcached]: https://github.com/zombiezen/nixcached 96 | 97 | ## Inputs 98 | 99 | ### `substituters` 100 | 101 | (Required) One or more space-separated cache URLs (typically starts with `s3://`) 102 | 103 | ### `trusted_public_keys` 104 | 105 | Space-separated trusted keys for signed downloads. 106 | Not required if a private key is given. 107 | 108 | ### `secret_keys` 109 | 110 | Private keys for signing built artifacts. 111 | If provided, built derivations will be uploaded to the first substituter. 112 | 113 | ### `aws_access_key_id` 114 | 115 | Access key ID for downloading and uploading artifacts 116 | 117 | ### `aws_secret_access_key` 118 | 119 | Secret access key for downloading and uploading artifacts 120 | 121 | ### `use_nixcached` 122 | 123 | If `true`, use [nixcached][] for uploading and downloading. 124 | This permits concurrent uploading and more straightforward authentication. 125 | 126 | ### `nixcached_upload_options` 127 | 128 | Additional arguments to send to `nixcached upload`. 129 | POSIX-shell-style quoting is supported. 130 | 131 | ## License 132 | 133 | [Apache 2.0](LICENSE) 134 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Ross Light 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: Set up Nix cache 18 | description: Configure Nix to read from (and optionally write to) a custom cache 19 | inputs: 20 | substituters: 21 | description: >- 22 | One or more space-separated cache URLs (typically starts with s3://) 23 | required: true 24 | trusted_public_keys: 25 | description: >- 26 | Space-separated trusted keys for signed downloads. 27 | Not required if a private key is given. 28 | required: false 29 | secret_keys: 30 | description: >- 31 | Private keys for signing built artifacts. 32 | If provided, built derivations will be uploaded to the first substituter. 33 | required: false 34 | use_nixcached: 35 | description: >- 36 | Whether to use github.com/zombiezen/nixcached to provide concurrent uploads 37 | and cached downloads. 38 | Only available on Linux. 39 | default: 'false' 40 | nixcached_upload_options: 41 | description: >- 42 | Additional arguments to `nixcached upload`. 43 | required: false 44 | aws_access_key_id: 45 | description: >- 46 | Access key ID for downloading and uploading artifacts 47 | required: false 48 | aws_secret_access_key: 49 | description: >- 50 | Secret access key for downloading and uploading artifacts 51 | required: false 52 | runs: 53 | using: node20 54 | main: dist/index.js 55 | post: dist/cleanup.js 56 | branding: 57 | color: blue 58 | icon: database 59 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | module.exports = { 18 | presets: [ 19 | ['@babel/preset-env', { targets: { node: 'current' } }], 20 | '@babel/preset-typescript', 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | require('esbuild') 18 | .build({ 19 | entryPoints: ['src/index.ts', 'src/cleanup.ts'], 20 | bundle: true, 21 | sourcemap: false, 22 | minify: true, 23 | platform: 'node', 24 | target: ['node20.9.0'], 25 | outdir: 'dist', 26 | }) 27 | .catch(() => process.exit(1)); 28 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1676283394, 6 | "narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "id": "flake-utils", 14 | "type": "indirect" 15 | } 16 | }, 17 | "nixpkgs": { 18 | "locked": { 19 | "lastModified": 1698890957, 20 | "narHash": "sha256-DJ+SppjpPBoJr0Aro9TAcP3sxApCSieY6BYBCoWGUX8=", 21 | "owner": "NixOS", 22 | "repo": "nixpkgs", 23 | "rev": "c082856b850ec60cda9f0a0db2bc7bd8900d708c", 24 | "type": "github" 25 | }, 26 | "original": { 27 | "id": "nixpkgs", 28 | "type": "indirect" 29 | } 30 | }, 31 | "root": { 32 | "inputs": { 33 | "flake-utils": "flake-utils", 34 | "nixpkgs": "nixpkgs" 35 | } 36 | } 37 | }, 38 | "root": "root", 39 | "version": 7 40 | } 41 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "GitHub Action that configures the Nix package manager to read from (and optionally write to) a remote cache."; 3 | 4 | inputs = { 5 | nixpkgs.url = "nixpkgs"; 6 | flake-utils.url = "flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils, ... }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | pkgs = import nixpkgs { inherit system; }; 13 | in { 14 | packages.nodejs = pkgs.nodejs_20; 15 | 16 | devShells.default = pkgs.mkShell { 17 | packages = [ 18 | self.packages.${system}.nodejs 19 | pkgs.google-cloud-sdk 20 | ]; 21 | }; 22 | 23 | checks.precommit = pkgs.stdenvNoCC.mkDerivation { 24 | name = "setup-nix-cache-action-precommit"; 25 | 26 | src = ./.; 27 | 28 | __impure = true; 29 | 30 | nativeBuildInputs = [ 31 | self.packages.${system}.nodejs 32 | ]; 33 | 34 | buildPhase = '' 35 | runHook preBuild 36 | export HOME="$(mktemp -d)" 37 | npm install 38 | patchShebangs --build node_modules pre-commit.sh 39 | ./pre-commit.sh 40 | runHook postBuild 41 | ''; 42 | 43 | installPhase = '' 44 | runHook preInstall 45 | touch "$out" 46 | runHook postInstall 47 | ''; 48 | }; 49 | 50 | checks.test = pkgs.stdenvNoCC.mkDerivation { 51 | name = "setup-nix-cache-action-test"; 52 | 53 | src = ./.; 54 | 55 | __impure = true; 56 | 57 | nativeBuildInputs = [ 58 | pkgs.coreutils 59 | self.packages.${system}.nodejs 60 | ]; 61 | 62 | buildPhase = '' 63 | runHook preBuild 64 | export HOME="$(mktemp -d)" 65 | npm install 66 | patchShebangs --build node_modules 67 | npm test 68 | runHook postBuild 69 | ''; 70 | 71 | installPhase = '' 72 | runHook preInstall 73 | touch "$out" 74 | runHook postInstall 75 | ''; 76 | }; 77 | } 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /install-pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright 2022 Ross Light 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | GIT_DIR="$(git rev-parse --absolute-git-dir)" 20 | cat > "$GIT_DIR/hooks/pre-commit" < /dev/null; then 23 | exec direnv exec . bash pre-commit.sh 24 | else 25 | exec ./pre-commit.sh 26 | fi 27 | EOF 28 | chmod +x "$GIT_DIR/hooks/pre-commit" 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "setup-nix-cache-action", 3 | "version": "0.4.0", 4 | "description": "Configure Nix to read from (and optionally write to) a custom cache", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc --build tsconfig.json && node build.js", 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@actions/core": "^1.10.1", 12 | "@actions/io": "^1.1.3", 13 | "esbuild": "^0.21.5", 14 | "node-fetch": "^2.7.0", 15 | "shlex": "^2.1.2", 16 | "tmp": "^0.2.3" 17 | }, 18 | "devDependencies": { 19 | "@actions/github": "^6.0.0", 20 | "@babel/core": "^7.16.7", 21 | "@babel/preset-env": "^7.16.7", 22 | "@babel/preset-typescript": "^7.16.7", 23 | "@types/jest": "^27.4.0", 24 | "@types/node": "^20.8.10", 25 | "@types/node-fetch": "^2.6.9", 26 | "@types/tmp": "^0.2.3", 27 | "babel-jest": "^27.4.6", 28 | "jest": "^27.4.7", 29 | "prettier": "^3.0.3", 30 | "typescript": "^4.5.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright 2022 Ross Light 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | set -euo pipefail 20 | 21 | jsfiles=( dist/index.js dist/cleanup.js ) 22 | for name in "${jsfiles[@]}"; do 23 | if [[ ! -e "$name" ]]; then 24 | echo "Bundled JavaScript out of sync; run: npm run build" 1>&2 25 | exit 1 26 | fi 27 | done 28 | for name in "${jsfiles[@]}"; do 29 | mv "$name" "${name}.bak" 30 | done 31 | cleanup() { 32 | for name in "${jsfiles[@]}"; do 33 | mv "${name}.bak" "$name" 34 | done 35 | } 36 | trap cleanup EXIT 37 | 38 | if [[ ! -e node_modules ]]; then 39 | # Workaround for https://github.com/npm/cli/issues/3314 40 | npm install --silent --no-progress |& tee 1>&2 41 | fi 42 | npm run --silent build 43 | for name in "${jsfiles[@]}"; do 44 | if ! cmp --quiet "${name}.bak" "$name"; then 45 | echo "Bundled JavaScript out of sync; run: npm run build" 1>&2 46 | exit 1 47 | fi 48 | done 49 | -------------------------------------------------------------------------------- /scripts/hash_binaries.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | // Copyright 2023 Ross Light 4 | // 5 | // Licensed under the Apache License, Version 2.0 (the "License"); 6 | // you may not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // https://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, software 12 | // distributed under the License is distributed on an "AS IS" BASIS, 13 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | // See the License for the specific language governing permissions and 15 | // limitations under the License. 16 | // 17 | // SPDX-License-Identifier: Apache-2.0 18 | 19 | import { createHash } from 'node:crypto'; 20 | import * as process from 'node:process'; 21 | 22 | import { getOctokit } from '@actions/github'; 23 | import fetch from 'node-fetch'; 24 | 25 | const QUERY = ` 26 | query ($tagName: String!) { 27 | repository(owner: "zombiezen", name: "nixcached") { 28 | release(tagName: $tagName) { 29 | releaseAssets(first: 50) { 30 | nodes { 31 | name 32 | downloadUrl 33 | } 34 | } 35 | } 36 | } 37 | } 38 | `; 39 | 40 | /** 41 | * 42 | * @param {string} url 43 | * @returns {Promise} The SHA-256 hash 44 | */ 45 | async function hashUrl(url) { 46 | const hasher = createHash('sha256'); 47 | const response = await fetch(url); 48 | if (!response.ok) { 49 | throw new Error(`GET ${url} ${response.status}`); 50 | } 51 | if (!response.body) { 52 | return hasher.digest('hex'); 53 | } 54 | 55 | response.body.pipe(hasher); 56 | 57 | return new Promise((resolve) => { 58 | hasher.on('finish', () => { 59 | resolve(hasher.digest('hex')); 60 | }); 61 | }); 62 | } 63 | 64 | (async () => { 65 | const octokit = getOctokit(process.env.GITHUB_TOKEN ?? ''); 66 | 67 | const { 68 | repository: { 69 | release: { 70 | releaseAssets: { nodes: releaseAssets }, 71 | }, 72 | }, 73 | } = await octokit.graphql(QUERY, { 74 | tagName: process.argv[2], 75 | }); 76 | 77 | /** @type {Record>} */ 78 | const result = {}; 79 | 80 | for (const asset of releaseAssets) { 81 | /** @type {{name: string, downloadUrl: string}} */ 82 | const { name, downloadUrl } = asset; 83 | /** @type {{os: string, arch: string}} */ 84 | let platform; 85 | if (name.endsWith('-linux_amd64')) { 86 | platform = { os: 'linux', arch: 'x64' }; 87 | } else if (name.endsWith('-linux_arm64')) { 88 | platform = { os: 'linux', arch: 'arm64' }; 89 | } else if (name.endsWith('-darwin_amd64')) { 90 | platform = { os: 'darwin', arch: 'x64' }; 91 | } else if (name.endsWith('-darwin_arm64')) { 92 | platform = { os: 'darwin', arch: 'arm64' }; 93 | } else { 94 | continue; 95 | } 96 | 97 | result[platform.os] = result[platform.os] || {}; 98 | result[platform.os][platform.arch] = { 99 | url: downloadUrl, 100 | sha256: await hashUrl(downloadUrl), 101 | }; 102 | } 103 | 104 | /** @type {Promise} */ 105 | const writePromise = new Promise((resolve, reject) => { 106 | process.stdout.write(JSON.stringify(result, undefined, 2) + '\n', (err) => { 107 | if (err) { 108 | reject(err); 109 | } else { 110 | resolve(); 111 | } 112 | }); 113 | }); 114 | await writePromise; 115 | })(); 116 | -------------------------------------------------------------------------------- /src/cleanup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | @license 3 | Copyright 2022 Ross Light 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | https://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | SPDX-License-Identifier: Apache-2.0 18 | **/ 19 | 20 | import { error, getState, info, setFailed } from '@actions/core'; 21 | 22 | import { 23 | runRootCommand, 24 | TEMP_DIR_STATE, 25 | SYSTEMD_DROPIN_STATE, 26 | SERVICES_STATE, 27 | runCommand, 28 | UPLOAD_SERVICE_UNIT, 29 | queryCommand, 30 | NIXCACHED_EXE_STATE, 31 | NIXCACHED_PIPE_STATE, 32 | } from './common'; 33 | 34 | async function shutDownUploadService( 35 | nixcachedExe: string, 36 | nixcachedPipe: string, 37 | ) { 38 | const journalctlAbort = new AbortController(); 39 | const journalctlPromise = runCommand( 40 | ['journalctl', `--user-unit=${UPLOAD_SERVICE_UNIT}`, '--follow'], 41 | { 42 | signal: journalctlAbort.signal, 43 | directStdoutToStderr: true, 44 | }, 45 | ); 46 | 47 | await runCommand( 48 | [nixcachedExe, 'send', '--finish', `--output=${nixcachedPipe}`], 49 | { 50 | directStdoutToStderr: true, 51 | }, 52 | ); 53 | 54 | // Wait until service has exited. 55 | for (;;) { 56 | // Always wait a little bit because we want to give journalctl space to show output. 57 | await new Promise((resolve) => setTimeout(resolve, 1000)); 58 | 59 | const result = await queryCommand( 60 | ['systemctl', 'is-active', '--user', '--quiet', UPLOAD_SERVICE_UNIT], 61 | { directStdoutToStderr: true }, 62 | ); 63 | if (result !== 0) { 64 | break; 65 | } 66 | } 67 | 68 | // Stop journalctl subprocess. 69 | journalctlAbort.abort(); 70 | try { 71 | await journalctlPromise; 72 | } catch (_) { 73 | // Since we signal journalctl to stop, it will never succeed. 74 | } 75 | } 76 | 77 | (async () => { 78 | try { 79 | const servicesStateData = getState(SERVICES_STATE); 80 | if (servicesStateData) { 81 | let servicesStarted = JSON.parse(servicesStateData); 82 | if ( 83 | servicesStarted instanceof Array && 84 | servicesStarted.every((x) => typeof x === 'string') 85 | ) { 86 | if (servicesStarted.indexOf(UPLOAD_SERVICE_UNIT) >= 0) { 87 | const nixcachedExe = getState(NIXCACHED_EXE_STATE); 88 | const nixcachedPipe = getState(NIXCACHED_PIPE_STATE); 89 | if (!nixcachedExe || !nixcachedPipe) { 90 | error( 91 | `Running ${UPLOAD_SERVICE_UNIT}, but ${NIXCACHED_EXE_STATE} and/or ${NIXCACHED_PIPE_STATE} not set in state!`, 92 | ); 93 | } else { 94 | await shutDownUploadService(nixcachedExe, nixcachedPipe); 95 | servicesStarted = servicesStarted.filter( 96 | (x) => x != UPLOAD_SERVICE_UNIT, 97 | ); 98 | } 99 | } 100 | 101 | info(`Stopping systemd services ${servicesStarted.join()}...`); 102 | await runCommand(['systemctl', 'stop', '--user', ...servicesStarted]); 103 | } 104 | } 105 | 106 | const tempDir = getState(TEMP_DIR_STATE); 107 | if (tempDir) { 108 | info('Removing ' + tempDir + '...'); 109 | await runRootCommand(['rm', '-rf', tempDir]); 110 | } 111 | const systemdDropinState = getState(SYSTEMD_DROPIN_STATE); 112 | if (systemdDropinState) { 113 | info('Removing ' + systemdDropinState + '...'); 114 | await runRootCommand(['rm', '-f', systemdDropinState]); 115 | } 116 | } catch (error) { 117 | if (error instanceof Error) { 118 | setFailed(error.message); 119 | } else { 120 | setFailed(error + ''); 121 | } 122 | } 123 | })(); 124 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | import { StdioNull, spawn } from 'child_process'; 18 | 19 | /** State key for the temporary directory to remove. */ 20 | export const TEMP_DIR_STATE = 'tempdir'; 21 | /** State key for the Nix systemd unit drop-in. */ 22 | export const SYSTEMD_DROPIN_STATE = 'systemd'; 23 | /** State key for a JSON array of systemd services that need to be shut down. */ 24 | export const SERVICES_STATE = 'systemd_services'; 25 | /** State key for path to the nixcached executable. */ 26 | export const NIXCACHED_EXE_STATE = 'nixcached_exe'; 27 | /** State key for path to the `nixcached upload` pipe. */ 28 | export const NIXCACHED_PIPE_STATE = 'nixcached_pipe'; 29 | 30 | export const UPLOAD_SERVICE_UNIT = 'nixcached-upload.service'; 31 | 32 | export interface CommandOptions { 33 | directStdoutToStderr?: boolean; 34 | ignoreStderr?: boolean; 35 | signal?: AbortSignal; 36 | } 37 | 38 | /** 39 | * Run a subprocess. 40 | * The returned promise will be rejected 41 | * if the subprocess exits with a non-zero exit code. 42 | */ 43 | export function runCommand( 44 | argv: string[], 45 | options?: CommandOptions, 46 | ): Promise { 47 | return queryCommand(argv, options).then((code) => { 48 | if (code !== 0) { 49 | throw new Error(argv[0] + ' exited with ' + code); 50 | } 51 | }); 52 | } 53 | 54 | /** 55 | * Run a subprocess. 56 | * 57 | * @returns The exit code of the subprocess. 58 | */ 59 | export function queryCommand( 60 | argv: string[], 61 | options?: CommandOptions, 62 | ): Promise { 63 | const proc = spawn(argv[0], argv.slice(1), { 64 | stdio: stdioFromOptions(options), 65 | }); 66 | return new Promise((resolve, reject) => { 67 | let listener: ((_event: Event) => void) | undefined; 68 | if (options?.signal) { 69 | listener = (_event: Event): void => { 70 | proc.kill('SIGTERM'); 71 | }; 72 | options.signal.addEventListener('abort', listener); 73 | } 74 | proc.on('close', (code) => { 75 | if (options?.signal && listener) { 76 | options.signal.removeEventListener('abort', listener); 77 | } 78 | if (code !== null) { 79 | resolve(code); 80 | } else { 81 | reject(new Error(argv[0] + ' exited from signal')); 82 | } 83 | }); 84 | }); 85 | } 86 | 87 | export function runRootCommand( 88 | argv: string[], 89 | options?: CommandOptions, 90 | ): Promise { 91 | return runCommand(['sudo', '--non-interactive', '--', ...argv], options); 92 | } 93 | 94 | function stdioFromOptions( 95 | options: CommandOptions | undefined, 96 | ): [StdioNull, StdioNull | number, StdioNull] { 97 | return [ 98 | 'ignore', 99 | options?.directStdoutToStderr ? 2 : 'ignore', 100 | options?.ignoreStderr ? 'ignore' : 'inherit', 101 | ]; 102 | } 103 | -------------------------------------------------------------------------------- /src/config_gen.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | import { readFile, rm, stat } from 'fs/promises'; 18 | import * as http from 'http'; 19 | import type { AddressInfo } from 'net'; 20 | import { join as joinPath } from 'path'; 21 | import { dir as tmpDir, DirOptions as TmpDirOptions } from 'tmp'; 22 | 23 | import { downloadFile, generate, GenerateResults } from './config_gen'; 24 | 25 | describe('generate', () => { 26 | describe('public mirror', () => { 27 | let results: GenerateResults; 28 | beforeAll(async () => { 29 | results = await generate({ 30 | substituters: ['https://cache.example.com'], 31 | }); 32 | }); 33 | afterAll(async () => { 34 | if (results.tempDir) { 35 | await rm(results.tempDir, { recursive: true, force: true }); 36 | } 37 | }); 38 | 39 | it('contains a substituters line', () => { 40 | const lines = results.config.split('\n'); 41 | expect(lines).toContain('extra-substituters = https://cache.example.com'); 42 | }); 43 | 44 | it('does not create a directory', () => { 45 | expect(results.tempDir).toBeNull(); 46 | }); 47 | 48 | it('does not download nixcached', () => { 49 | expect(results.nixcachedPipe).toBeNull(); 50 | }); 51 | }); 52 | 53 | describe('public cache', () => { 54 | let results: GenerateResults; 55 | beforeAll(async () => { 56 | results = await generate({ 57 | substituters: ['https://cache.example.com'], 58 | trustedPublicKeys: ['example-1:xyzzy'], 59 | }); 60 | }); 61 | afterAll(async () => { 62 | if (results.tempDir) { 63 | await rm(results.tempDir, { recursive: true, force: true }); 64 | } 65 | }); 66 | 67 | it('contains a substituters line', () => { 68 | const lines = results.config.split('\n'); 69 | expect(lines).toContain('extra-substituters = https://cache.example.com'); 70 | }); 71 | 72 | it('contains a trusted-public-keys line', () => { 73 | const lines = results.config.split('\n'); 74 | expect(lines).toContain('extra-trusted-public-keys = example-1:xyzzy'); 75 | }); 76 | 77 | it('does not create a directory', () => { 78 | expect(results.tempDir).toBeNull(); 79 | }); 80 | 81 | it('does not download nixcached', () => { 82 | expect(results.nixcachedPipe).toBeNull(); 83 | }); 84 | }); 85 | 86 | describe('secret keys', () => { 87 | let results: GenerateResults; 88 | beforeAll(async () => { 89 | results = await generate({ 90 | substituters: ['https://cache.example.com'], 91 | secretKeys: ['example-1:sekret'], 92 | }); 93 | }); 94 | afterAll(async () => { 95 | if (results.tempDir) { 96 | await rm(results.tempDir, { recursive: true, force: true }); 97 | } 98 | }); 99 | 100 | it('contains a substituters line', () => { 101 | const lines = results.config.split('\n'); 102 | expect(lines).toContain('extra-substituters = https://cache.example.com'); 103 | }); 104 | 105 | it('creates a secret key file', async () => { 106 | const prefix = 'extra-secret-key-files = '; 107 | const secretKeyLines = results.config 108 | .split('\n') 109 | .filter((line) => line.startsWith(prefix)); 110 | expect(secretKeyLines).toHaveLength(1); 111 | const path = secretKeyLines[0].substring(prefix.length); 112 | const keyData = await readFile(path, { encoding: 'utf-8' }); 113 | expect(keyData).toEqual('example-1:sekret'); 114 | }); 115 | 116 | it('sets a post-build-hook', async () => { 117 | const lines = results.config.split('\n'); 118 | expect(lines).toContainEqual(expect.stringMatching(/^post-build-hook =/)); 119 | }); 120 | 121 | it('post-build-hook contains substituter', async () => { 122 | const prefix = 'post-build-hook = '; 123 | const postBuildHookLines = results.config 124 | .split('\n') 125 | .filter((line) => line.startsWith(prefix)); 126 | expect(postBuildHookLines.length).toBeGreaterThan(0); 127 | const path = postBuildHookLines[0].substring(prefix.length); 128 | const hookScript = await readFile(path, { encoding: 'utf-8' }); 129 | expect(hookScript).toEqual( 130 | expect.stringContaining('https://cache.example.com'), 131 | ); 132 | }); 133 | 134 | it('post-build-hook ends in newline', async () => { 135 | const prefix = 'post-build-hook = '; 136 | const postBuildHookLines = results.config 137 | .split('\n') 138 | .filter((line) => line.startsWith(prefix)); 139 | expect(postBuildHookLines.length).toBeGreaterThan(0); 140 | const path = postBuildHookLines[0].substring(prefix.length); 141 | const hookScript = await readFile(path, { encoding: 'utf-8' }); 142 | expect(hookScript).toEqual(expect.stringMatching(/\n$/)); 143 | }); 144 | }); 145 | 146 | describe('S3 cache', () => { 147 | let results: GenerateResults; 148 | beforeAll(async () => { 149 | results = await generate({ 150 | substituters: ['s3://example-bucket'], 151 | secretKeys: ['example-1:sekret'], 152 | awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE', 153 | awsSecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', 154 | }); 155 | }); 156 | afterAll(async () => { 157 | if (results.tempDir) { 158 | await rm(results.tempDir, { recursive: true, force: true }); 159 | } 160 | }); 161 | 162 | it('contains a substituters line', () => { 163 | const lines = results.config.split('\n'); 164 | expect(lines).toContain('extra-substituters = s3://example-bucket'); 165 | }); 166 | 167 | it('stores AWS credentials', async () => { 168 | expect(results.credsPath).toBeTruthy(); 169 | const credsData = await readFile(results.credsPath!, { 170 | encoding: 'utf-8', 171 | }); 172 | expect(credsData).toEqual( 173 | '[default]\n' + 174 | 'aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n' + 175 | 'aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n', 176 | ); 177 | }); 178 | 179 | it('post-build-hook contains credentials path', async () => { 180 | expect(results.credsPath).toBeTruthy(); 181 | const prefix = 'post-build-hook = '; 182 | const postBuildHookLines = results.config 183 | .split('\n') 184 | .filter((line) => line.startsWith(prefix)); 185 | expect(postBuildHookLines).toHaveLength(1); 186 | const path = postBuildHookLines[0].substring(prefix.length); 187 | const hookScript = await readFile(path, { encoding: 'utf-8' }); 188 | expect(hookScript).toEqual(expect.stringContaining(results.credsPath!)); 189 | }); 190 | 191 | it('post-build-hook ends in newline', async () => { 192 | const prefix = 'post-build-hook = '; 193 | const postBuildHookLines = results.config 194 | .split('\n') 195 | .filter((line) => line.startsWith(prefix)); 196 | expect(postBuildHookLines.length).toBeGreaterThan(0); 197 | const path = postBuildHookLines[0].substring(prefix.length); 198 | const hookScript = await readFile(path, { encoding: 'utf-8' }); 199 | expect(hookScript).toEqual(expect.stringMatching(/\n$/)); 200 | }); 201 | }); 202 | 203 | describe('nixcached', () => { 204 | let results: GenerateResults; 205 | beforeAll(async () => { 206 | results = await generate({ 207 | substituters: ['http://localhost:8080'], 208 | secretKeys: ['example-1:sekret'], 209 | useNixcached: true, 210 | }); 211 | }); 212 | afterAll(async () => { 213 | if (results.tempDir) { 214 | await rm(results.tempDir, { recursive: true, force: true }); 215 | } 216 | }); 217 | 218 | it('contains a substituters line', () => { 219 | const lines = results.config.split('\n'); 220 | expect(lines).toContain('extra-substituters = http://localhost:8080'); 221 | }); 222 | 223 | it('sets a post-build-hook', async () => { 224 | const lines = results.config.split('\n'); 225 | expect(lines).toContainEqual(expect.stringMatching(/^post-build-hook =/)); 226 | }); 227 | 228 | it('created an upload pipe', async () => { 229 | expect(results.nixcachedPipe).not.toBeNull(); 230 | const info = await stat(results.nixcachedPipe!); 231 | expect(info.isFIFO()).toBe(true); 232 | }); 233 | 234 | it('post-build-hook references pipe', async () => { 235 | expect(results.nixcachedPipe).not.toBeNull(); 236 | 237 | const prefix = 'post-build-hook = '; 238 | const postBuildHookLines = results.config 239 | .split('\n') 240 | .filter((line) => line.startsWith(prefix)); 241 | expect(postBuildHookLines.length).toBeGreaterThan(0); 242 | const path = postBuildHookLines[0].substring(prefix.length); 243 | const hookScript = await readFile(path, { encoding: 'utf-8' }); 244 | expect(hookScript).toEqual( 245 | expect.stringContaining(results.nixcachedPipe!), 246 | ); 247 | }); 248 | 249 | it('post-build-hook references nixcached', async () => { 250 | expect(results.nixcachedExe).not.toBeNull(); 251 | 252 | const prefix = 'post-build-hook = '; 253 | const postBuildHookLines = results.config 254 | .split('\n') 255 | .filter((line) => line.startsWith(prefix)); 256 | expect(postBuildHookLines.length).toBeGreaterThan(0); 257 | const path = postBuildHookLines[0].substring(prefix.length); 258 | const hookScript = await readFile(path, { encoding: 'utf-8' }); 259 | expect(hookScript).toEqual( 260 | expect.stringContaining(results.nixcachedExe!), 261 | ); 262 | }); 263 | 264 | it('post-build-hook ends in newline', async () => { 265 | const prefix = 'post-build-hook = '; 266 | const postBuildHookLines = results.config 267 | .split('\n') 268 | .filter((line) => line.startsWith(prefix)); 269 | expect(postBuildHookLines.length).toBeGreaterThan(0); 270 | const path = postBuildHookLines[0].substring(prefix.length); 271 | const hookScript = await readFile(path, { encoding: 'utf-8' }); 272 | expect(hookScript).toEqual(expect.stringMatching(/\n$/)); 273 | }); 274 | 275 | it('downloaded nixcached', async () => { 276 | expect(results.nixcachedExe).not.toBeNull(); 277 | const info = await stat(results.nixcachedExe!); 278 | expect(info.mode & 0o100).not.toEqual(0); 279 | }); 280 | }); 281 | }); 282 | 283 | describe('downloadFile', () => { 284 | const dummyContent = 'Hello, World!\n'; 285 | const server = http.createServer((req, res) => { 286 | if (!req.url) { 287 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 288 | res.end('not found'); 289 | return; 290 | } 291 | const u = new URL(req.url, `http://${req.headers.host}`); 292 | switch (u.pathname) { 293 | case '/200': 294 | res.writeHead(200, { 295 | 'Content-Type': 'text/plain', 296 | 'Content-Length': dummyContent.length, 297 | }); 298 | res.end(dummyContent); 299 | break; 300 | case '/204': 301 | res.writeHead(204); 302 | res.end(); 303 | break; 304 | default: 305 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 306 | res.end('not found'); 307 | break; 308 | } 309 | }); 310 | 311 | let serverUrl: string; 312 | beforeAll(async () => { 313 | serverUrl = await new Promise((resolve) => { 314 | server.listen(() => { 315 | const addr = server.address() as AddressInfo; 316 | resolve(`http://localhost:${addr.port}`); 317 | }); 318 | }); 319 | }); 320 | afterAll(async () => { 321 | await new Promise((resolve, reject) => { 322 | server.close((err) => { 323 | if (err) { 324 | reject(err); 325 | } else { 326 | resolve(); 327 | } 328 | }); 329 | }); 330 | }); 331 | 332 | let tempDir: string; 333 | beforeAll(async () => { 334 | tempDir = await new Promise((resolve, reject) => { 335 | const options: TmpDirOptions = { 336 | prefix: 'setup-nix-cache-action-test', 337 | }; 338 | tmpDir(options, (err, name) => { 339 | if (err) { 340 | reject(err); 341 | } else { 342 | resolve(name); 343 | } 344 | }); 345 | }); 346 | }); 347 | afterAll(async () => { 348 | await rm(tempDir, { recursive: true, force: true }); 349 | }); 350 | 351 | describe('200', () => { 352 | let got: string; 353 | let dstPath: string; 354 | beforeAll(async () => { 355 | dstPath = joinPath(tempDir, '200'); 356 | got = await downloadFile(dstPath, serverUrl + '/200'); 357 | }); 358 | 359 | it('created a file', async () => { 360 | const info = await stat(dstPath); 361 | expect(info.isFile()).toBe(true); 362 | expect(info.size).toEqual(dummyContent.length); 363 | }); 364 | 365 | it('returned the expected hash', () => { 366 | expect(got).toEqual( 367 | 'c98c24b677eff44860afea6f493bbaec5bb1c4cbb209c6fc2bbb47f66ff2ad31', 368 | ); 369 | }); 370 | 371 | it('wrote the correct content to the file', async () => { 372 | const got = await readFile(dstPath, 'utf-8'); 373 | expect(got).toEqual(dummyContent); 374 | }); 375 | }); 376 | 377 | describe('204', () => { 378 | let got: string; 379 | let dstPath: string; 380 | beforeAll(async () => { 381 | dstPath = joinPath(tempDir, '204'); 382 | got = await downloadFile(dstPath, serverUrl + '/204'); 383 | }); 384 | 385 | it('created a zero-length file', async () => { 386 | const info = await stat(dstPath); 387 | expect(info.isFile()).toBe(true); 388 | expect(info.size).toEqual(0); 389 | }); 390 | 391 | it('returned the expected hash', () => { 392 | expect(got).toEqual( 393 | 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', 394 | ); 395 | }); 396 | }); 397 | 398 | describe('404', () => { 399 | it('throws an error', async () => { 400 | const dstPath = joinPath(tempDir, '404'); 401 | await expect( 402 | downloadFile(dstPath, serverUrl + '/404'), 403 | ).rejects.toBeTruthy(); 404 | }); 405 | }); 406 | }); 407 | -------------------------------------------------------------------------------- /src/config_gen.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Ross Light 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | import { debug, warning } from '@actions/core'; 18 | import { which } from '@actions/io'; 19 | import { createHash } from 'crypto'; 20 | import { createWriteStream } from 'fs'; 21 | import type { PathLike } from 'fs'; 22 | import { chmod, rm, writeFile } from 'fs/promises'; 23 | import fetch from 'node-fetch'; 24 | import * as path from 'path'; 25 | import { platform, arch } from 'process'; 26 | import { dir as tmpDir, DirOptions as TmpDirOptions } from 'tmp'; 27 | import { runCommand } from './common'; 28 | 29 | export interface GenerateInput { 30 | substituters: string[]; 31 | trustedPublicKeys?: string[]; 32 | secretKeys?: string[]; 33 | awsAccessKeyId?: string; 34 | awsSecretAccessKey?: string; 35 | useNixcached?: boolean; 36 | } 37 | 38 | export interface GenerateResults { 39 | config: string; 40 | tempDir: string | null; 41 | credsPath: string | null; 42 | /** Path to a nixcached executable. */ 43 | nixcachedExe: string | null; 44 | /** Path to the nixcached upload pipe. */ 45 | nixcachedPipe: string | null; 46 | } 47 | 48 | const nixcachedBinaries: { 49 | [K in NodeJS.Platform]?: Record; 50 | } = { 51 | linux: { 52 | x64: { 53 | url: 'https://github.com/zombiezen/nixcached/releases/download/v0.3.3/nixcached-0.3.3-linux_amd64', 54 | sha256: 55 | 'ac1507259aacd7bbcf83916d8e23a587cb659dc6ca9285738981ee8e799b429b', 56 | }, 57 | arm64: { 58 | url: 'https://github.com/zombiezen/nixcached/releases/download/v0.3.3/nixcached-0.3.3-linux_arm64', 59 | sha256: 60 | 'a19f6578563140a1bad67b184fb9bf73c1721e05ff13e80cec4fc3e7fc65207b', 61 | }, 62 | }, 63 | darwin: { 64 | x64: { 65 | url: 'https://github.com/zombiezen/nixcached/releases/download/v0.3.3/nixcached-0.3.3-darwin_amd64', 66 | sha256: 67 | '88fb89110e1313b03786d7da3569a4ad0c5ff47fac5dda6679bee47f4acbca63', 68 | }, 69 | arm64: { 70 | url: 'https://github.com/zombiezen/nixcached/releases/download/v0.3.3/nixcached-0.3.3-darwin_arm64', 71 | sha256: 72 | 'e832eb15ef835feacad9b34da2823f63349cde4b8de5fedab71a86fec7d84490', 73 | }, 74 | }, 75 | }; 76 | export async function generate(input: GenerateInput): Promise { 77 | const { 78 | substituters, 79 | trustedPublicKeys, 80 | secretKeys, 81 | awsAccessKeyId, 82 | awsSecretAccessKey, 83 | useNixcached, 84 | } = input; 85 | 86 | let config = 'extra-substituters = ' + substituters.join(' ') + '\n'; 87 | if (trustedPublicKeys) { 88 | config += 89 | 'extra-trusted-public-keys = ' + trustedPublicKeys.join(' ') + '\n'; 90 | } 91 | 92 | let tempDir: string | null = null; 93 | let credsPath: string | null = null; 94 | let nixcachedExe: string | null = null; 95 | let nixcachedPipe: string | null = null; 96 | try { 97 | const hasAwsKey = awsAccessKeyId && awsSecretAccessKey; 98 | if (secretKeys || hasAwsKey || useNixcached) { 99 | tempDir = await new Promise((resolve, reject) => { 100 | const options: TmpDirOptions = { 101 | keep: true, 102 | prefix: 'setup-nix-cache-action', 103 | mode: 0o755, 104 | }; 105 | tmpDir(options, (err, name) => { 106 | if (err) { 107 | reject(err); 108 | } else { 109 | resolve(name); 110 | } 111 | }); 112 | }); 113 | 114 | if (useNixcached) { 115 | const binariesForPlatform = nixcachedBinaries[platform]; 116 | const binaryInfo = binariesForPlatform 117 | ? binariesForPlatform[arch] 118 | : undefined; 119 | if (!binaryInfo) { 120 | throw new Error(`nixcached not supported on ${platform}/${arch}`); 121 | } 122 | nixcachedExe = path.join(tempDir, 'nixcached'); 123 | debug(`Downloading ${binaryInfo.url} to ${nixcachedExe}`); 124 | const gotSha256 = await downloadFile(nixcachedExe, binaryInfo.url); 125 | if (gotSha256 != binaryInfo.sha256) { 126 | throw new Error( 127 | `nixcached binary at ${binaryInfo.url} does not match expected hash`, 128 | ); 129 | } 130 | debug(`Successfully downloaded ${binaryInfo.url}`); 131 | await chmod(nixcachedExe, 0o755); 132 | 133 | nixcachedPipe = path.join(tempDir, 'nixcached-upload-pipe'); 134 | debug(`mkfifo ${nixcachedPipe}`); 135 | await runCommand(['mkfifo', nixcachedPipe]); 136 | } 137 | 138 | if (hasAwsKey) { 139 | credsPath = path.join(tempDir, 'aws-credentials'); 140 | await writeFile( 141 | credsPath, 142 | `[default] 143 | aws_access_key_id = ${awsAccessKeyId} 144 | aws_secret_access_key = ${awsSecretAccessKey} 145 | `, 146 | { mode: 0o600 }, 147 | ); 148 | debug('Wrote ' + credsPath); 149 | } 150 | 151 | if (secretKeys) { 152 | const keyFiles = []; 153 | for (let i = 0; i < secretKeys.length; i++) { 154 | const k = secretKeys[i]; 155 | const keyPath = path.join(tempDir, 'secret-key' + i); 156 | await writeFile(keyPath, k, { mode: 0o600 }); 157 | debug('Wrote ' + keyPath); 158 | keyFiles.push(keyPath); 159 | } 160 | config += 'extra-secret-key-files = ' + keyFiles.join(' ') + '\n'; 161 | 162 | const bash = await which('bash'); 163 | let hook = `#!${bash} 164 | set -euo pipefail 165 | echo "Uploading paths: $OUT_PATHS" 1>&2 166 | `; 167 | if (useNixcached) { 168 | hook += 169 | `echo "$OUT_PATHS" | xargs '${nixcachedExe}' send --output='${nixcachedPipe}' 1>&2` + 170 | '\n'; 171 | } else { 172 | if (hasAwsKey) { 173 | hook += `export AWS_SHARED_CREDENTIALS_FILE='${credsPath}'` + '\n'; 174 | } 175 | hook += 176 | `echo "$OUT_PATHS" | xargs /nix/var/nix/profiles/default/bin/nix \\ 177 | --extra-experimental-features nix-command \\ 178 | copy --to '${substituters[0]}' 1>&2` + '\n'; 179 | } 180 | const hookPath = path.join(tempDir, 'post-build-hook'); 181 | await writeFile(hookPath, hook, { mode: 0o755 }); 182 | debug('Wrote ' + hookPath); 183 | config += 'post-build-hook = ' + hookPath + '\n'; 184 | } 185 | } 186 | return { config, tempDir, credsPath, nixcachedExe, nixcachedPipe }; 187 | } catch (e) { 188 | if (tempDir) { 189 | await rm(tempDir, { recursive: true, force: true }).catch((e) => { 190 | warning(e); 191 | }); 192 | } 193 | throw e; 194 | } 195 | } 196 | 197 | /** 198 | * Downloads a URL and writes its content to the given file 199 | * while also hashing it. 200 | * 201 | * @returns The hex-encoded SHA-256 hash of the file. 202 | */ 203 | export async function downloadFile( 204 | dstPath: PathLike, 205 | url: string, 206 | ): Promise { 207 | const hasher = createHash('sha256'); 208 | const file = createWriteStream(dstPath); 209 | const response = await fetch(url); 210 | if (!response.ok) { 211 | throw new Error(`GET ${url} ${response.status}`); 212 | } 213 | if (!response.body) { 214 | await new Promise((resolve, reject) => { 215 | file.close((err) => { 216 | if (err) { 217 | reject(err); 218 | } else { 219 | resolve(); 220 | } 221 | }); 222 | }); 223 | return hasher.digest('hex'); 224 | } 225 | 226 | response.body.pipe(hasher); 227 | response.body.pipe(file); 228 | 229 | return new Promise((resolve, reject) => { 230 | file.on('error', (err) => { 231 | file.close(() => { 232 | reject(err); 233 | }); 234 | }); 235 | response.body!.on('error', (err) => { 236 | file.close(() => { 237 | reject(err); 238 | }); 239 | }); 240 | 241 | let finishes = 0; 242 | const finishCallback = () => { 243 | finishes++; 244 | if (finishes === 2) { 245 | resolve(hasher.digest('hex')); 246 | } 247 | }; 248 | file.on('finish', finishCallback); 249 | hasher.on('finish', finishCallback); 250 | }); 251 | } 252 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | @license 3 | Copyright 2022 Ross Light 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | https://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | SPDX-License-Identifier: Apache-2.0 18 | **/ 19 | 20 | import { 21 | debug, 22 | getBooleanInput, 23 | getInput, 24 | info, 25 | saveState, 26 | setFailed, 27 | startGroup, 28 | endGroup, 29 | } from '@actions/core'; 30 | import { spawn } from 'child_process'; 31 | import { stat } from 'fs/promises'; 32 | import { platform } from 'process'; 33 | import { split as shlexSplit } from 'shlex'; 34 | 35 | import { 36 | NIXCACHED_EXE_STATE, 37 | NIXCACHED_PIPE_STATE, 38 | runCommand, 39 | runRootCommand, 40 | SERVICES_STATE, 41 | SYSTEMD_DROPIN_STATE, 42 | TEMP_DIR_STATE, 43 | UPLOAD_SERVICE_UNIT, 44 | } from './common'; 45 | import { generate } from './config_gen'; 46 | 47 | async function writeAsRoot( 48 | dstFile: string, 49 | data: string, 50 | options?: { append?: boolean }, 51 | ): Promise { 52 | const argv = options?.append 53 | ? ['--non-interactive', 'tee', '-a', dstFile] 54 | : ['--non-interactive', 'tee', dstFile]; 55 | const proc = spawn('sudo', argv, { 56 | stdio: ['pipe', 'ignore', 'inherit'], 57 | }); 58 | const exitPromise = new Promise((resolve, reject) => { 59 | proc.on('close', (code) => { 60 | if (code === 0) { 61 | resolve(); 62 | } else { 63 | reject(new Error('tee exited with ' + code)); 64 | } 65 | }); 66 | }); 67 | try { 68 | await new Promise((resolve, reject) => { 69 | proc.stdin.write(data, (e) => { 70 | if (e) { 71 | reject(e); 72 | } else { 73 | resolve(); 74 | } 75 | }); 76 | }); 77 | await new Promise((resolve) => { 78 | proc.stdin.end(resolve); 79 | }); 80 | } catch (e) { 81 | proc.kill(); 82 | throw e; 83 | } finally { 84 | await exitPromise; 85 | } 86 | } 87 | 88 | async function restartNixDaemon(): Promise { 89 | if (platform === 'linux') { 90 | const hasDaemon = await runRootCommand( 91 | ['systemctl', 'cat', 'nix-daemon.service'], 92 | { ignoreStderr: true }, 93 | ).then( 94 | () => true, 95 | () => false, 96 | ); 97 | if (!hasDaemon) { 98 | return; 99 | } 100 | info('Restarting Nix daemon...'); 101 | await runRootCommand(['systemctl', 'daemon-reload']); 102 | await runRootCommand(['systemctl', 'restart', 'nix-daemon.service']); 103 | } else if (platform === 'darwin') { 104 | const hasDaemon = await stat( 105 | '/Library/LaunchDaemons/org.nixos.nix-daemon.plist', 106 | ).then( 107 | () => true, 108 | () => false, 109 | ); 110 | if (!hasDaemon) { 111 | return; 112 | } 113 | info('Restarting Nix daemon...'); 114 | await runRootCommand([ 115 | 'launchctl', 116 | 'kickstart', 117 | '-k', 118 | 'system/org.nixos.nix-daemon', 119 | ]); 120 | } else { 121 | throw new Error( 122 | 'cannot restart Nix daemon on unknown platform ' + platform, 123 | ); 124 | } 125 | } 126 | 127 | function spaceSepList(s: string): string[] { 128 | s = s.trim(); 129 | return s ? s.split(/\s+/) : []; 130 | } 131 | 132 | const NIXCACHED_PORT = 38380; 133 | 134 | (async () => { 135 | try { 136 | if (platform === 'linux') { 137 | startGroup('systemd version'); 138 | await runCommand(['systemctl', '--version'], { 139 | directStdoutToStderr: true, 140 | }); 141 | endGroup(); 142 | } 143 | 144 | info('Generating configuration...'); 145 | const useNixcached = getBooleanInput('use_nixcached'); 146 | if (useNixcached && platform !== 'linux') { 147 | throw new Error('nixcached not supported on ' + platform); 148 | } 149 | const substituters = spaceSepList( 150 | getInput('substituters', { required: true }), 151 | ); 152 | if (useNixcached && substituters.length !== 1) { 153 | throw new Error('Must have exactly 1 substituter for nixcached'); 154 | } 155 | const { 156 | config: newConfig, 157 | tempDir, 158 | credsPath, 159 | nixcachedExe, 160 | nixcachedPipe, 161 | } = await generate({ 162 | useNixcached, 163 | substituters: useNixcached 164 | ? [`http://localhost:${NIXCACHED_PORT}`] 165 | : substituters, 166 | trustedPublicKeys: spaceSepList(getInput('trusted_public_keys')), 167 | secretKeys: spaceSepList(getInput('secret_keys')), 168 | awsAccessKeyId: getInput('aws_access_key_id'), 169 | awsSecretAccessKey: getInput('aws_secret_access_key'), 170 | }); 171 | if (tempDir) { 172 | if (!useNixcached) { 173 | runRootCommand(['chown', '-R', 'root:root', tempDir]); 174 | } 175 | saveState(TEMP_DIR_STATE, tempDir); 176 | } 177 | 178 | // Start nixcached, if needed. 179 | if (nixcachedExe) { 180 | saveState(NIXCACHED_EXE_STATE, nixcachedExe); 181 | 182 | const setenvFlags = [`--setenv=PATH=${process.env.PATH}`]; 183 | if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { 184 | const x = `GOOGLE_APPLICATION_CREDENTIALS=${process.env.GOOGLE_APPLICATION_CREDENTIALS}`; 185 | debug(`Using ${x} for nixcached`); 186 | setenvFlags.push(`--setenv=${x}`); 187 | } 188 | if (credsPath || process.env.AWS_SHARED_CREDENTIALS_FILE) { 189 | const x = `AWS_SHARED_CREDENTIALS_FILE=${ 190 | credsPath || process.env.AWS_SHARED_CREDENTIALS_FILE 191 | }`; 192 | debug(`Using ${x} for nixcached`); 193 | setenvFlags.push(`--setenv=${x}`); 194 | } 195 | 196 | debug('Starting nixcached serve...'); 197 | const SERVE_SERVICE = 'nixcached-serve.service'; 198 | const servicesStarted = [SERVE_SERVICE]; 199 | await runCommand([ 200 | 'systemd-run', 201 | '--user', 202 | `--unit=${SERVE_SERVICE}`, 203 | ...setenvFlags, 204 | nixcachedExe, 205 | 'serve', 206 | `--port=${NIXCACHED_PORT}`, 207 | '--', 208 | substituters[0], 209 | ]); 210 | saveState(SERVICES_STATE, servicesStarted); 211 | 212 | if (nixcachedPipe) { 213 | saveState(NIXCACHED_PIPE_STATE, nixcachedPipe); 214 | 215 | debug('Starting nixcached upload...'); 216 | servicesStarted.push(UPLOAD_SERVICE_UNIT); 217 | const extraArgs = shlexSplit(getInput('nixcached_upload_options')); 218 | await runCommand([ 219 | 'systemd-run', 220 | '--user', 221 | `--unit=${UPLOAD_SERVICE_UNIT}`, 222 | '--property=KillMode=mixed', 223 | '--property=KillSignal=SIGHUP', 224 | ...setenvFlags, 225 | nixcachedExe, 226 | 'upload', 227 | ...extraArgs, 228 | '--keep-alive', 229 | '--allow-finish', 230 | `--input=${nixcachedPipe}`, 231 | '--', 232 | substituters[0], 233 | ]); 234 | saveState(SERVICES_STATE, servicesStarted); 235 | } 236 | } 237 | 238 | info('Saving configuration...'); 239 | await runRootCommand(['mkdir', '-p', '/etc/nix']); 240 | await writeAsRoot('/etc/nix/nix.conf', newConfig, { append: true }); 241 | if (credsPath && !useNixcached) { 242 | if (platform !== 'linux') { 243 | // TODO(darwin): launchctl equivalent? 244 | throw new Error('Private substituters not supported on ' + platform); 245 | } 246 | await runRootCommand([ 247 | 'mkdir', 248 | '-p', 249 | '/etc/systemd/system/nix-daemon.service.d', 250 | ]); 251 | const dropinPath = 252 | '/etc/systemd/system/nix-daemon.service.d/aws-credentials.conf'; 253 | await writeAsRoot( 254 | dropinPath, 255 | '[Service]\n' + 256 | 'Environment=AWS_SHARED_CREDENTIALS_FILE=' + 257 | credsPath + 258 | '\n', 259 | ); 260 | saveState(SYSTEMD_DROPIN_STATE, dropinPath); 261 | } 262 | 263 | await restartNixDaemon(); 264 | info('Done!'); 265 | } catch (error) { 266 | if (error instanceof Error) { 267 | setFailed(error.message); 268 | } else { 269 | setFailed(error + ''); 270 | } 271 | } 272 | })(); 273 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "incremental": true, 5 | "module": "commonjs", 6 | "target": "es2018", 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "strict": true, 10 | "allowUnreachableCode": false, 11 | "allowUnusedLabels": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmitOnError": true, 14 | "noFallthroughCasesInSwitch": false, 15 | "noImplicitAny": true, 16 | "noImplicitReturns": true, 17 | "noUnusedLocals": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------