├── .eslintignore ├── .prettierignore ├── .gitattributes ├── test-projects ├── core │ ├── meson.build │ ├── hello.c │ └── snap │ │ └── snapcraft.yaml ├── core18 │ ├── meson.build │ ├── hello.c │ └── snap │ │ └── snapcraft.yaml ├── core20 │ ├── meson.build │ ├── hello.c │ └── snap │ │ └── snapcraft.yaml ├── core22 │ ├── meson.build │ ├── hello.c │ └── snap │ │ └── snapcraft.yaml └── core24 │ ├── meson.build │ ├── hello.c │ └── snap │ └── snapcraft.yaml ├── .prettierrc.json ├── jest.config.js ├── src ├── main.ts ├── build.ts └── tools.ts ├── LICENSE ├── tsconfig.json ├── CONTRIBUTING.md ├── package.json ├── action.yml ├── .gitignore ├── .github └── workflows │ └── test.yml ├── .eslintrc.json ├── README.md └── __tests__ ├── build.test.ts └── tools.test.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/ linguist-generated=true 2 | 3 | -------------------------------------------------------------------------------- /test-projects/core/meson.build: -------------------------------------------------------------------------------- 1 | project('test-build-action', 'c', version : '0.1') 2 | 3 | executable('hello', 'hello.c', install : true) 4 | -------------------------------------------------------------------------------- /test-projects/core18/meson.build: -------------------------------------------------------------------------------- 1 | project('test-build-action', 'c', version : '0.1') 2 | 3 | executable('hello', 'hello.c', install : true) 4 | -------------------------------------------------------------------------------- /test-projects/core20/meson.build: -------------------------------------------------------------------------------- 1 | project('test-build-action', 'c', version : '0.1') 2 | 3 | executable('hello', 'hello.c', install : true) 4 | -------------------------------------------------------------------------------- /test-projects/core22/meson.build: -------------------------------------------------------------------------------- 1 | project('test-build-action', 'c', version : '0.1') 2 | 3 | executable('hello', 'hello.c', install : true) 4 | -------------------------------------------------------------------------------- /test-projects/core24/meson.build: -------------------------------------------------------------------------------- 1 | project('test-build-action', 'c', version : '0.1') 2 | 3 | executable('hello', 'hello.c', install : true) 4 | -------------------------------------------------------------------------------- /test-projects/core/hello.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main(int argc, char **argv) { 4 | printf("Hello world\n"); 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /test-projects/core18/hello.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main(int argc, char **argv) { 4 | printf("Hello world\n"); 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /test-projects/core20/hello.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main(int argc, char **argv) { 4 | printf("Hello world\n"); 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /test-projects/core22/hello.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main(int argc, char **argv) { 4 | printf("Hello world\n"); 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /test-projects/core24/hello.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main(int argc, char **argv) { 4 | printf("Hello world\n"); 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid", 10 | "parser": "typescript" 11 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest' 9 | }, 10 | verbose: true 11 | } -------------------------------------------------------------------------------- /test-projects/core18/snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: test-build-action-core18 2 | base: core18 3 | version: '0.1' 4 | summary: A simple test snap used to test the Github build action 5 | description: ... 6 | 7 | grade: devel 8 | confinement: strict 9 | 10 | apps: 11 | test-build-action: 12 | command: bin/hello 13 | 14 | parts: 15 | build: 16 | plugin: meson 17 | meson-parameters: 18 | - --prefix=/ 19 | source: . 20 | -------------------------------------------------------------------------------- /test-projects/core20/snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: test-build-action-core20 2 | base: core20 3 | version: '0.1' 4 | summary: A simple test snap used to test the Github build action 5 | description: ... 6 | 7 | grade: devel 8 | confinement: strict 9 | 10 | apps: 11 | test-build-action: 12 | command: bin/hello 13 | 14 | parts: 15 | build: 16 | plugin: meson 17 | meson-parameters: 18 | - --prefix=/ 19 | source: . 20 | -------------------------------------------------------------------------------- /test-projects/core22/snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: test-build-action-core22 2 | base: core22 3 | version: '0.1' 4 | summary: A simple test snap used to test the Github build action 5 | description: ... 6 | 7 | grade: devel 8 | confinement: strict 9 | 10 | apps: 11 | test-build-action: 12 | command: bin/hello 13 | 14 | parts: 15 | build: 16 | plugin: meson 17 | build-packages: 18 | - meson 19 | meson-parameters: 20 | - --prefix=/ 21 | source: . 22 | -------------------------------------------------------------------------------- /test-projects/core24/snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: test-build-action-core24 2 | base: core24 3 | version: '0.1' 4 | summary: A simple test snap used to test the Github build action 5 | description: ... 6 | 7 | grade: devel 8 | confinement: strict 9 | 10 | apps: 11 | test-build-action: 12 | command: bin/hello 13 | 14 | parts: 15 | build: 16 | plugin: meson 17 | build-packages: 18 | - meson 19 | meson-parameters: 20 | - --prefix=/ 21 | source: . 22 | -------------------------------------------------------------------------------- /test-projects/core/snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: test-build-action-core 2 | base: core 3 | version: '0.1' 4 | summary: A simple test snap used to test the Github build action 5 | description: ... 6 | 7 | grade: devel 8 | confinement: strict 9 | 10 | apps: 11 | test-build-action: 12 | command: bin/hello 13 | 14 | parts: 15 | build: 16 | plugin: meson 17 | meson-version: 0.53.2 # Meson >= 0.54 requires a newer Ninja 18 | meson-parameters: 19 | - --prefix=/ 20 | source: . 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as core from '@actions/core' 4 | import {SnapcraftBuilder} from './build' 5 | 6 | async function run(): Promise { 7 | try { 8 | const projectRoot = core.getInput('path') 9 | const includeBuildInfo = 10 | (core.getInput('build-info') || 'true').toUpperCase() === 'TRUE' 11 | core.info(`Building Snapcraft project in "${projectRoot}"...`) 12 | const snapcraftChannel = core.getInput('snapcraft-channel') 13 | const snapcraftArgs = core.getInput('snapcraft-args') 14 | const uaToken = core.getInput('ua-token') 15 | 16 | const builder = new SnapcraftBuilder({ 17 | projectRoot, 18 | includeBuildInfo, 19 | snapcraftChannel, 20 | snapcraftArgs, 21 | uaToken 22 | }) 23 | await builder.build() 24 | const snap = await builder.outputSnap() 25 | core.setOutput('snap', snap) 26 | } catch (error) { 27 | core.setFailed((error as Error)?.message) 28 | } 29 | } 30 | 31 | run() 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2020 James Henstridge and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "outDir": "./lib", /* Redirect output structure to the directory. */ 8 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 9 | "strict": true, /* Enable all strict type-checking options. */ 10 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 11 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 12 | }, 13 | "exclude": ["node_modules", "**/*.test.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to snapcraft-build-action 2 | 3 | This action is written in TypeScript using Github's template action 4 | project as a starting point. The unit tests can be run locally, and 5 | the repository includes a Github Actions workflow that will invoke the 6 | in-tree version of the action. 7 | 8 | After cloning the repository, the dependencies can be installed with: 9 | ```bash 10 | $ npm install 11 | ``` 12 | 13 | The TypeScript code in `src/` can be compiled to JavaScript with: 14 | ```bash 15 | $ npm run build 16 | ``` 17 | 18 | The tests in `__tests__/` can be run with: 19 | ```bash 20 | $ npm test 21 | ``` 22 | 23 | The packed JavaScript actually run by the Github Actions system can be 24 | built with: 25 | ```bash 26 | $ npm run pack 27 | ``` 28 | 29 | If you are putting together a pull request, you can run through all 30 | steps including code reformatting and linting with: 31 | ```bash 32 | $ npm run all 33 | ``` 34 | 35 | ## Making Releases 36 | 37 | 1. Update the version number in `package.json`, commit and push. 38 | 2. On the Github website, make a release matching the version number (e.g. `v1.0.0`). 39 | 3. Update the `v1` tag to point at the new release revision. 40 | ``` 41 | git tag -fa v1 -m "Update v1 tag" 42 | git push origin v1 --force 43 | ``` 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snapcraft-build-action", 3 | "version": "1.3.0", 4 | "private": true, 5 | "description": "A Github action that build snapcraft projects", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc", 9 | "format": "prettier --write **/*.ts", 10 | "format-check": "prettier --check **/*.ts", 11 | "lint": "eslint src/**/*.ts", 12 | "pack": "ncc build", 13 | "test": "jest", 14 | "all": "npm run build && npm run format && npm run lint && npm run pack && npm test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/snapcore/action-build.git" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "node", 23 | "setup" 24 | ], 25 | "author": "James Henstridge", 26 | "license": "MIT", 27 | "dependencies": { 28 | "@actions/core": "^1.10.1", 29 | "@actions/exec": "^1.1.1" 30 | }, 31 | "devDependencies": { 32 | "@types/jest": "^29.5.12", 33 | "@types/node": "^20.14.10", 34 | "@typescript-eslint/eslint-plugin": "^7.16.0", 35 | "@typescript-eslint/parser": "^7.16.0", 36 | "@vercel/ncc": "^0.38.1", 37 | "eslint": "^8.56.0", 38 | "eslint-plugin-github": "^5.0.1", 39 | "eslint-plugin-jest": "^28.6.0", 40 | "eslint-plugin-prettier": "^5.1.3", 41 | "jest": "^29.7.0", 42 | "jest-circus": "^29.7.0", 43 | "prettier": "^3.3.3", 44 | "ts-jest": "^29.2.2", 45 | "typescript": "^5.5.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Snapcraft Build' 2 | description: 'Build a Snapcraft project' 3 | author: 'James Henstridge' 4 | branding: 5 | icon: 'package' 6 | color: 'orange' 7 | inputs: 8 | path: 9 | description: 'The location of the Snapcraft project. Defaults to the base of the repository' 10 | default: '.' 11 | build-info: 12 | description: > 13 | Whether to include build information in the resulting snap. 14 | 15 | This will add snap/manifest.yaml and snap/snapcraft.yaml files 16 | to the snap. The Snap Store can use this information to detect 17 | snaps with security vulnerabilities introduced through 18 | stage-packages. 19 | 20 | Proprietary applications may want to disable this due to 21 | the information leakage. 22 | default: 'true' 23 | snapcraft-channel: 24 | description: > 25 | The Snapcraft channel to use 26 | 27 | By default, the action will use the stable version of Snapcraft 28 | to build the project. This parameter can be used to instead 29 | select a different channel such as beta, candidate, or edge. 30 | default: 'stable' 31 | snapcraft-args: 32 | description: > 33 | Additional arguments to pass to Snapcraft 34 | 35 | Some experimental Snapcraft features are disabled by default and 36 | must be turned on via a `--enable-experimental-*` command line 37 | argument. This parameter can be used to turn on such features. 38 | default: '' 39 | ua-token: 40 | description: > 41 | UA token to attach in build environment. 42 | 43 | Snapcraft will detach the token when no longer required. 44 | default: '' 45 | outputs: 46 | snap: 47 | description: 'The file name of the resulting snap.' 48 | runs: 49 | using: 'node20' 50 | main: 'dist/index.js' 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib/**/* 100 | 101 | # Ignore backup files 102 | *~ 103 | *.swp 104 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "build-test" 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - 'releases/*' 8 | schedule: 9 | - cron: '0 9 * * *' 10 | 11 | jobs: 12 | unit: # make sure build/ci work properly 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: | 17 | npm install 18 | npm run all 19 | 20 | integration: # make sure the action works on a clean machine without building 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | project: 25 | - core20 26 | - core22 27 | - core24 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: ./ 31 | id: snapcraft 32 | with: 33 | path: './test-projects/${{ matrix.project }}' 34 | - uses: actions/upload-artifact@v4 35 | with: 36 | name: 'snap-${{ matrix.project }}' 37 | path: ${{ steps.snapcraft.outputs.snap}} 38 | compression-level: 0 39 | retention-days: 7 40 | 41 | integration-legacy: # make sure the action works on a clean machine without building 42 | runs-on: ubuntu-20.04 43 | strategy: 44 | matrix: 45 | project: 46 | - core 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: ./ 50 | id: snapcraft 51 | with: 52 | path: './test-projects/${{ matrix.project }}' 53 | snapcraft-channel: 4.x/stable 54 | - uses: actions/upload-artifact@v4 55 | with: 56 | name: 'snap-${{ matrix.project }}' 57 | path: ${{ steps.snapcraft.outputs.snap}} 58 | compression-level: 0 59 | retention-days: 7 60 | 61 | collect-artifacts: 62 | needs: 63 | - integration 64 | - integration-legacy 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/upload-artifact/merge@v4 68 | with: 69 | name: snap 70 | pattern: snap-* 71 | delete-merged: true 72 | retention-days: 7 73 | 74 | check-runner: # make sure the action works on each VM image 75 | strategy: 76 | matrix: 77 | runner: 78 | - ubuntu-20.04 79 | - ubuntu-22.04 80 | - ubuntu-24.04 81 | runs-on: ${{ matrix.runner }} 82 | steps: 83 | - uses: actions/checkout@v4 84 | - uses: ./ 85 | with: 86 | path: './test-projects/core22' 87 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "eslint-comments/no-use": "off", 12 | "i18n-text/no-en": "off", 13 | "import/no-namespace": "off", 14 | "no-unused-vars": "off", 15 | "@typescript-eslint/no-unused-vars": "error", 16 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}], 17 | "@typescript-eslint/no-require-imports": "error", 18 | "@typescript-eslint/array-type": "error", 19 | "@typescript-eslint/await-thenable": "error", 20 | "@typescript-eslint/ban-ts-comment": "error", 21 | "camelcase": "off", 22 | "@typescript-eslint/consistent-type-assertions": ["error", {"assertionStyle": "as", "objectLiteralTypeAssertions": "never"}], 23 | "@typescript-eslint/consistent-type-definitions": ["error", "interface"], 24 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}], 25 | "@typescript-eslint/func-call-spacing": ["error", "never"], 26 | "@typescript-eslint/naming-convention": "error", 27 | "@typescript-eslint/no-array-constructor": "error", 28 | "@typescript-eslint/no-empty-interface": "error", 29 | "@typescript-eslint/no-explicit-any": "error", 30 | "@typescript-eslint/no-extraneous-class": "error", 31 | "@typescript-eslint/no-for-in-array": "error", 32 | "@typescript-eslint/no-inferrable-types": "error", 33 | "@typescript-eslint/no-misused-new": "error", 34 | "@typescript-eslint/no-namespace": "error", 35 | "@typescript-eslint/no-non-null-assertion": "warn", 36 | "@typescript-eslint/no-unnecessary-qualifier": "error", 37 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 38 | "@typescript-eslint/no-useless-constructor": "error", 39 | "@typescript-eslint/no-var-requires": "error", 40 | "@typescript-eslint/prefer-for-of": "warn", 41 | "@typescript-eslint/prefer-function-type": "warn", 42 | "@typescript-eslint/prefer-includes": "error", 43 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 44 | "@typescript-eslint/promise-function-async": "error", 45 | "@typescript-eslint/require-array-sort-compare": "error", 46 | "@typescript-eslint/restrict-plus-operands": "error", 47 | "semi": "off", 48 | "@typescript-eslint/semi": ["error", "never"], 49 | "@typescript-eslint/type-annotation-spacing": "error", 50 | "@typescript-eslint/unbound-method": "error" 51 | }, 52 | "env": { 53 | "node": true, 54 | "es6": true, 55 | "jest/globals": true 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/build.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as core from '@actions/core' 4 | import * as exec from '@actions/exec' 5 | import * as fs from 'fs' 6 | import * as os from 'os' 7 | import * as path from 'path' 8 | import * as process from 'process' 9 | import * as tools from './tools' 10 | 11 | interface ImageInfo { 12 | // eslint-disable-next-line @typescript-eslint/naming-convention 13 | 'build-request-id'?: string 14 | // eslint-disable-next-line @typescript-eslint/naming-convention 15 | 'build-request-timestamp'?: string 16 | // eslint-disable-next-line @typescript-eslint/naming-convention 17 | build_url?: string 18 | } 19 | 20 | function expandHome(p: string): string { 21 | if (p === '~' || p.startsWith('~/')) { 22 | p = os.homedir() + p.slice(1) 23 | } 24 | return p 25 | } 26 | 27 | interface SnapcraftBuilderOptions { 28 | projectRoot: string 29 | includeBuildInfo: boolean 30 | snapcraftChannel: string 31 | snapcraftArgs: string 32 | uaToken: string 33 | } 34 | 35 | export class SnapcraftBuilder { 36 | projectRoot: string 37 | includeBuildInfo: boolean 38 | snapcraftChannel: string 39 | snapcraftArgs: string 40 | uaToken: string 41 | 42 | constructor(options: SnapcraftBuilderOptions) { 43 | this.projectRoot = expandHome(options.projectRoot) 44 | this.includeBuildInfo = options.includeBuildInfo 45 | this.snapcraftChannel = options.snapcraftChannel 46 | this.snapcraftArgs = options.snapcraftArgs 47 | this.uaToken = options.uaToken 48 | } 49 | 50 | async build(): Promise { 51 | core.startGroup('Installing Snapcraft plus dependencies') 52 | await tools.ensureSnapd() 53 | await tools.ensureLXD() 54 | await tools.ensureSnapcraft(this.snapcraftChannel) 55 | core.endGroup() 56 | 57 | const imageInfo: ImageInfo = { 58 | // eslint-disable-next-line @typescript-eslint/naming-convention 59 | build_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` 60 | } 61 | // Copy and update environment to pass to snapcraft 62 | const env: {[key: string]: string} = {} 63 | Object.assign(env, process.env) 64 | env['SNAPCRAFT_BUILD_ENVIRONMENT'] = 'lxd' 65 | env['SNAPCRAFT_IMAGE_INFO'] = JSON.stringify(imageInfo) 66 | if (this.includeBuildInfo) { 67 | env['SNAPCRAFT_BUILD_INFO'] = '1' 68 | } 69 | 70 | const snapcraft = ['snapcraft'] 71 | if (this.snapcraftArgs) { 72 | snapcraft.push(...this.snapcraftArgs.split(/\s+/)) 73 | } 74 | if (this.uaToken) { 75 | snapcraft.push('--ua-token', this.uaToken) 76 | } 77 | 78 | const user = os.userInfo().username 79 | await exec.exec('sudo', ['-u', user, '-E'].concat(snapcraft), { 80 | cwd: this.projectRoot, 81 | env 82 | }) 83 | } 84 | 85 | // This wrapper is for the benefit of the tests, due to the crazy 86 | // typing of fs.promises.readdir() 87 | async _readdir(dir: string): Promise { 88 | return await fs.promises.readdir(dir) 89 | } 90 | 91 | async outputSnap(): Promise { 92 | const files = await this._readdir(this.projectRoot) 93 | const snaps = files.filter(name => name.endsWith('.snap')) 94 | 95 | if (snaps.length === 0) { 96 | throw new Error('No snap files produced by build') 97 | } 98 | if (snaps.length > 1) { 99 | core.warning(`Multiple snaps found in ${this.projectRoot}`) 100 | } 101 | return path.join(this.projectRoot, snaps[0]) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as core from '@actions/core' 4 | import * as exec from '@actions/exec' 5 | import * as fs from 'fs' 6 | import * as os from 'os' 7 | 8 | async function haveExecutable(path: string): Promise { 9 | try { 10 | await fs.promises.access(path, fs.constants.X_OK) 11 | } catch (err) { 12 | return false 13 | } 14 | return true 15 | } 16 | 17 | export async function ensureSnapd(): Promise { 18 | const haveSnapd = await haveExecutable('/usr/bin/snap') 19 | if (!haveSnapd) { 20 | core.info('Installing snapd...') 21 | await exec.exec('sudo', ['apt-get', 'update', '-q']) 22 | await exec.exec('sudo', ['apt-get', 'install', '-qy', 'snapd']) 23 | } 24 | // The Github worker environment has weird permissions on the root, 25 | // which trip up snap-confine. 26 | const root = await fs.promises.stat('/') 27 | if (root.uid !== 0 || root.gid !== 0) { 28 | await exec.exec('sudo', ['chown', 'root:root', '/']) 29 | } 30 | } 31 | 32 | export async function ensureLXDNetwork(): Promise { 33 | const mobyPackages: string[] = [ 34 | 'moby-buildx', 35 | 'moby-engine', 36 | 'moby-cli', 37 | 'moby-compose', 38 | 'moby-containerd', 39 | 'moby-runc' 40 | ] 41 | const installedPackages: string[] = [] 42 | const options = {silent: true, ignoreReturnCode: true} 43 | for (const mobyPackage of mobyPackages) { 44 | if ((await exec.exec('dpkg', ['-l', mobyPackage], options)) === 0) { 45 | installedPackages.push(mobyPackage) 46 | } 47 | } 48 | core.info( 49 | `Installed docker related packages might interfere with LXD networking: ${installedPackages}` 50 | ) 51 | // Removing docker is the best option, but some pipelines depend on it. 52 | // https://linuxcontainers.org/lxd/docs/master/howto/network_bridge_firewalld/#prevent-issues-with-lxd-and-docker 53 | // https://github.com/canonical/lxd-cloud/blob/f20a64a8af42485440dcbfd370faf14137d2f349/test/includes/lxd.sh#L13-L23 54 | await exec.exec('sudo', ['iptables', '-P', 'FORWARD', 'ACCEPT']) 55 | } 56 | 57 | export async function ensureLXD(): Promise { 58 | const haveDebLXD = await haveExecutable('/usr/bin/lxd') 59 | if (haveDebLXD) { 60 | core.info('Removing legacy .deb packaged LXD...') 61 | await exec.exec('sudo', ['apt-get', 'remove', '-qy', 'lxd', 'lxd-client']) 62 | } 63 | 64 | core.info(`Ensuring ${os.userInfo().username} is in the lxd group...`) 65 | await exec.exec('sudo', ['groupadd', '--force', '--system', 'lxd']) 66 | await exec.exec('sudo', [ 67 | 'usermod', 68 | '--append', 69 | '--groups', 70 | 'lxd', 71 | os.userInfo().username 72 | ]) 73 | 74 | // Ensure that the "lxd" group exists 75 | const haveSnapLXD = await haveExecutable('/snap/bin/lxd') 76 | core.info('Installing LXD...') 77 | if (haveSnapLXD) { 78 | try { 79 | await exec.exec('sudo', ['snap', 'refresh', 'lxd']) 80 | } catch (err) { 81 | core.info('LXD could not be refreshed...') 82 | } 83 | } else { 84 | await exec.exec('sudo', ['snap', 'install', 'lxd']) 85 | } 86 | core.info('Initialising LXD...') 87 | await exec.exec('sudo', ['lxd', 'init', '--auto']) 88 | await ensureLXDNetwork() 89 | } 90 | 91 | export async function ensureSnapcraft(channel: string): Promise { 92 | const haveSnapcraft = await haveExecutable('/snap/bin/snapcraft') 93 | core.info('Installing Snapcraft...') 94 | await exec.exec('sudo', [ 95 | 'snap', 96 | haveSnapcraft ? 'refresh' : 'install', 97 | '--channel', 98 | channel, 99 | '--classic', 100 | 'snapcraft' 101 | ]) 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | snapcraft-build-action status 3 |

4 | 5 | # Snapcraft Build Action 6 | 7 | This is a Github Action that can be used to build a 8 | [Snapcraft](https://snapcraft.io) project. For most projects, the 9 | following workflow should be sufficient: 10 | 11 | ```yaml 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: snapcore/action-build@v1 18 | ``` 19 | 20 | This will install and configure LXD and Snapcraft, then invoke 21 | `snapcraft` to build the project. 22 | 23 | On success, the action will set the `snap` output parameter to the 24 | path of the built snap. This can be used to save it as an artifact of 25 | the workflow: 26 | 27 | ```yaml 28 | ... 29 | - uses: snapcore/action-build@v1 30 | id: snapcraft 31 | - uses: actions/upload-artifact@v3 32 | with: 33 | name: snap 34 | path: ${{ steps.snapcraft.outputs.snap }} 35 | ``` 36 | 37 | Alternatively, it could be used to perform further testing on the built snap: 38 | 39 | ```yaml 40 | - run: | 41 | sudo snap install --dangerous ${{ steps.snapcraft.outputs.snap }} 42 | # do something with the snap 43 | ``` 44 | 45 | The action can also be chained with 46 | [`snapcore/action-publish@v1`](https://github.com/snapcore/action-publish) 47 | to automatically publish builds to the Snap Store. 48 | 49 | 50 | ## Action inputs 51 | 52 | ### `path` 53 | 54 | If your Snapcraft project is not located in the root of the workspace, 55 | you can specify an alternative location via the `path` input 56 | parameter: 57 | 58 | ```yaml 59 | ... 60 | - uses: snapcore/action-build@v1 61 | with: 62 | path: path-to-snapcraft-project 63 | ``` 64 | 65 | ### `build-info` 66 | 67 | By default, the action will tell Snapcraft to include information 68 | about the build in the resulting snap, in the form of the 69 | `snap/snapcraft.yaml` and `snap/manifest.yaml` files. Among other 70 | things, these are used by the Snap Store's automatic security 71 | vulnerability scanner to check whether your snap includes files from 72 | vulnerable versions of Ubuntu packages. 73 | 74 | This can be turned off by setting the `build-info` parameter to 75 | `false`. 76 | 77 | ### `snapcraft-channel` 78 | 79 | By default, the action will install Snapcraft from the stable 80 | channel. If your project relies on a feature not found in the stable 81 | version of Snapcraft, then the `snapcraft-channel` parameter can be 82 | used to select a different channel. 83 | 84 | ### `snapcraft-args` 85 | 86 | The `snapcraft-args` parameter can be used to pass additional 87 | arguments to Snapcraft. This is primarily intended to allow the use 88 | of experimental features by passing `--enable-experimental-*` 89 | arguments to Snapcraft. 90 | 91 | ### `ua-token` 92 | 93 | The `ua-token` parameter can be used to tell Snapcraft to attach an Ubuntu 94 | Advantage (UA) token inside the build environment. Snapcraft will ensure 95 | the token is detached before exiting, but be warned that it is possible 96 | some failures may prevent detaching (e.g. aborted jobs). 97 | 98 | In order to make the UA token available to the workflow, it should be stored 99 | as a repository secret: 100 | 101 | 1. choose the "Settings" tab. 102 | 2. choose "Secrets" from the menu on the left. 103 | 3. click "Add a new secret". 104 | 4. set the name to `UA_TOKEN` (or whatever is referenced in the workflow), 105 | and paste the UA token as the value. 106 | 107 | Note that repository secrets are not available to workflows triggered 108 | by pull requests to public repositories. 109 | 110 | An example workflow with UA token stored as secret `UA_TOKEN`: 111 | 112 | ```yaml 113 | ... 114 | - uses: snapcore/action-build@v1 115 | with: 116 | ua-token: ${{ secrets.UA_TOKEN }} 117 | ``` 118 | -------------------------------------------------------------------------------- /__tests__/build.test.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as fs from 'fs' 4 | import * as os from 'os' 5 | import * as path from 'path' 6 | import * as process from 'process' 7 | import * as exec from '@actions/exec' 8 | import * as build from '../src/build' 9 | import * as tools from '../src/tools' 10 | 11 | afterEach(() => { 12 | jest.restoreAllMocks() 13 | }) 14 | 15 | test('SnapcraftBuilder expands tilde in project root', () => { 16 | let builder = new build.SnapcraftBuilder({ 17 | projectRoot: '~', 18 | includeBuildInfo: true, 19 | snapcraftChannel: 'stable', 20 | snapcraftArgs: '', 21 | uaToken: '' 22 | }) 23 | expect(builder.projectRoot).toBe(os.homedir()) 24 | 25 | builder = new build.SnapcraftBuilder({ 26 | projectRoot: '~/foo/bar', 27 | includeBuildInfo: true, 28 | snapcraftChannel: 'stable', 29 | snapcraftArgs: '', 30 | uaToken: '' 31 | }) 32 | expect(builder.projectRoot).toBe(path.join(os.homedir(), 'foo/bar')) 33 | }) 34 | 35 | test('SnapcraftBuilder.build runs a snap build', async () => { 36 | expect.assertions(4) 37 | 38 | const ensureSnapd = jest 39 | .spyOn(tools, 'ensureSnapd') 40 | .mockImplementation(async (): Promise => {}) 41 | const ensureLXD = jest 42 | .spyOn(tools, 'ensureLXD') 43 | .mockImplementation(async (): Promise => {}) 44 | const ensureSnapcraft = jest 45 | .spyOn(tools, 'ensureSnapcraft') 46 | .mockImplementation(async (channel): Promise => {}) 47 | const execMock = jest 48 | .spyOn(exec, 'exec') 49 | .mockImplementation( 50 | async (program: string, args?: string[]): Promise => { 51 | return 0 52 | } 53 | ) 54 | process.env['GITHUB_REPOSITORY'] = 'user/repo' 55 | process.env['GITHUB_RUN_ID'] = '42' 56 | 57 | const projectDir = 'project-root' 58 | const builder = new build.SnapcraftBuilder({ 59 | projectRoot: projectDir, 60 | includeBuildInfo: true, 61 | snapcraftChannel: 'stable', 62 | snapcraftArgs: '', 63 | uaToken: '' 64 | }) 65 | await builder.build() 66 | 67 | const user = os.userInfo().username 68 | expect(ensureSnapd).toHaveBeenCalled() 69 | expect(ensureLXD).toHaveBeenCalled() 70 | expect(ensureSnapcraft).toHaveBeenCalled() 71 | expect(execMock).toHaveBeenCalledWith( 72 | 'sudo', 73 | ['-u', user, '-E', 'snapcraft'], 74 | { 75 | cwd: projectDir, 76 | env: expect.objectContaining({ 77 | SNAPCRAFT_BUILD_ENVIRONMENT: 'lxd', 78 | SNAPCRAFT_BUILD_INFO: '1', 79 | SNAPCRAFT_IMAGE_INFO: 80 | '{"build_url":"https://github.com/user/repo/actions/runs/42"}' 81 | }) 82 | } 83 | ) 84 | }) 85 | 86 | test('SnapcraftBuilder.build can disable build info', async () => { 87 | expect.assertions(1) 88 | 89 | const ensureSnapd = jest 90 | .spyOn(tools, 'ensureSnapd') 91 | .mockImplementation(async (): Promise => {}) 92 | const ensureLXD = jest 93 | .spyOn(tools, 'ensureLXD') 94 | .mockImplementation(async (): Promise => {}) 95 | const ensureSnapcraft = jest 96 | .spyOn(tools, 'ensureSnapcraft') 97 | .mockImplementation(async (channel): Promise => {}) 98 | const execMock = jest 99 | .spyOn(exec, 'exec') 100 | .mockImplementation( 101 | async (program: string, args?: string[]): Promise => { 102 | return 0 103 | } 104 | ) 105 | 106 | const builder = new build.SnapcraftBuilder({ 107 | projectRoot: '.', 108 | includeBuildInfo: false, 109 | snapcraftChannel: 'stable', 110 | snapcraftArgs: '', 111 | uaToken: '' 112 | }) 113 | await builder.build() 114 | 115 | expect(execMock).toHaveBeenCalledWith('sudo', expect.any(Array), { 116 | cwd: expect.any(String), 117 | env: expect.not.objectContaining({ 118 | // No SNAPCRAFT_BUILD_INFO variable 119 | SNAPCRAFT_BUILD_INFO: expect.anything() 120 | }) 121 | }) 122 | }) 123 | 124 | test('SnapcraftBuilder.build can set the Snapcraft channel', async () => { 125 | expect.assertions(1) 126 | 127 | const ensureSnapd = jest 128 | .spyOn(tools, 'ensureSnapd') 129 | .mockImplementation(async (): Promise => {}) 130 | const ensureLXD = jest 131 | .spyOn(tools, 'ensureLXD') 132 | .mockImplementation(async (): Promise => {}) 133 | const ensureSnapcraft = jest 134 | .spyOn(tools, 'ensureSnapcraft') 135 | .mockImplementation(async (channel): Promise => {}) 136 | const execMock = jest 137 | .spyOn(exec, 'exec') 138 | .mockImplementation( 139 | async (program: string, args?: string[]): Promise => { 140 | return 0 141 | } 142 | ) 143 | 144 | const builder = new build.SnapcraftBuilder({ 145 | projectRoot: '.', 146 | includeBuildInfo: false, 147 | snapcraftChannel: 'edge', 148 | snapcraftArgs: '', 149 | uaToken: '' 150 | }) 151 | await builder.build() 152 | 153 | expect(ensureSnapcraft).toHaveBeenCalledWith('edge') 154 | }) 155 | 156 | test('SnapcraftBuilder.build can pass additional arguments', async () => { 157 | expect.assertions(1) 158 | 159 | const ensureSnapd = jest 160 | .spyOn(tools, 'ensureSnapd') 161 | .mockImplementation(async (): Promise => {}) 162 | const ensureLXD = jest 163 | .spyOn(tools, 'ensureLXD') 164 | .mockImplementation(async (): Promise => {}) 165 | const ensureSnapcraft = jest 166 | .spyOn(tools, 'ensureSnapcraft') 167 | .mockImplementation(async (channel): Promise => {}) 168 | const execMock = jest 169 | .spyOn(exec, 'exec') 170 | .mockImplementation( 171 | async (program: string, args?: string[]): Promise => { 172 | return 0 173 | } 174 | ) 175 | 176 | const builder = new build.SnapcraftBuilder({ 177 | projectRoot: '.', 178 | includeBuildInfo: false, 179 | snapcraftChannel: 'stable', 180 | snapcraftArgs: '--foo --bar', 181 | uaToken: '' 182 | }) 183 | await builder.build() 184 | 185 | const user = os.userInfo().username 186 | expect(execMock).toHaveBeenCalledWith( 187 | 'sudo', 188 | ['-u', user, '-E', 'snapcraft', '--foo', '--bar'], 189 | expect.anything() 190 | ) 191 | }) 192 | 193 | test('SnapcraftBuilder.build can pass UA token', async () => { 194 | expect.assertions(1) 195 | 196 | const ensureSnapd = jest 197 | .spyOn(tools, 'ensureSnapd') 198 | .mockImplementation(async (): Promise => {}) 199 | const ensureLXD = jest 200 | .spyOn(tools, 'ensureLXD') 201 | .mockImplementation(async (): Promise => {}) 202 | const ensureSnapcraft = jest 203 | .spyOn(tools, 'ensureSnapcraft') 204 | .mockImplementation(async (channel): Promise => {}) 205 | const execMock = jest 206 | .spyOn(exec, 'exec') 207 | .mockImplementation( 208 | async (program: string, args?: string[]): Promise => { 209 | return 0 210 | } 211 | ) 212 | 213 | const builder = new build.SnapcraftBuilder({ 214 | projectRoot: '.', 215 | includeBuildInfo: false, 216 | snapcraftChannel: 'stable', 217 | snapcraftArgs: '', 218 | uaToken: 'test-ua-token' 219 | }) 220 | await builder.build() 221 | 222 | const user = os.userInfo().username 223 | expect(execMock).toHaveBeenCalledWith( 224 | 'sudo', 225 | ['-u', user, '-E', 'snapcraft', '--ua-token', 'test-ua-token'], 226 | expect.anything() 227 | ) 228 | }) 229 | 230 | test('SnapcraftBuilder.outputSnap fails if there are no snaps', async () => { 231 | expect.assertions(2) 232 | 233 | const projectDir = 'project-root' 234 | const builder = new build.SnapcraftBuilder({ 235 | projectRoot: projectDir, 236 | includeBuildInfo: true, 237 | snapcraftChannel: 'stable', 238 | snapcraftArgs: '', 239 | uaToken: '' 240 | }) 241 | 242 | const readdir = jest 243 | .spyOn(builder, '_readdir') 244 | .mockImplementation( 245 | async (path: string): Promise => ['not-a-snap'] 246 | ) 247 | 248 | await expect(builder.outputSnap()).rejects.toThrow( 249 | 'No snap files produced by build' 250 | ) 251 | expect(readdir).toHaveBeenCalled() 252 | }) 253 | 254 | test('SnapcraftBuilder.outputSnap returns the first snap', async () => { 255 | expect.assertions(2) 256 | 257 | const projectDir = 'project-root' 258 | const builder = new build.SnapcraftBuilder({ 259 | projectRoot: projectDir, 260 | includeBuildInfo: true, 261 | snapcraftChannel: 'stable', 262 | snapcraftArgs: '', 263 | uaToken: '' 264 | }) 265 | 266 | const readdir = jest 267 | .spyOn(builder, '_readdir') 268 | .mockImplementation( 269 | async (path: string): Promise => ['one.snap', 'two.snap'] 270 | ) 271 | 272 | await expect(builder.outputSnap()).resolves.toEqual('project-root/one.snap') 273 | expect(readdir).toHaveBeenCalled() 274 | }) 275 | -------------------------------------------------------------------------------- /__tests__/tools.test.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as fs from 'fs' 4 | import * as os from 'os' 5 | import * as path from 'path' 6 | import * as core from '@actions/core' 7 | import * as exec from '@actions/exec' 8 | import * as tools from '../src/tools' 9 | 10 | afterEach(() => { 11 | jest.restoreAllMocks() 12 | }) 13 | 14 | test('ensureSnapd installs snapd if needed', async () => { 15 | expect.assertions(4) 16 | 17 | const accessMock = jest 18 | .spyOn(fs.promises, 'access') 19 | .mockImplementation( 20 | async ( 21 | filename: fs.PathLike, 22 | mode?: number | undefined 23 | ): Promise => { 24 | throw new Error('not found') 25 | } 26 | ) 27 | const statMock = jest 28 | .spyOn(fs.promises, 'stat') 29 | .mockImplementation(async (filename: fs.PathLike): Promise => { 30 | const s = new fs.Stats() 31 | s.uid = 0 32 | s.gid = 0 33 | return s 34 | }) 35 | const execMock = jest 36 | .spyOn(exec, 'exec') 37 | .mockImplementation( 38 | async (program: string, args?: string[]): Promise => { 39 | return 0 40 | } 41 | ) 42 | 43 | await tools.ensureSnapd() 44 | 45 | expect(accessMock).toHaveBeenCalled() 46 | expect(statMock).toHaveBeenCalled() 47 | expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ 48 | 'apt-get', 49 | 'update', 50 | '-q' 51 | ]) 52 | expect(execMock).toHaveBeenNthCalledWith(2, 'sudo', [ 53 | 'apt-get', 54 | 'install', 55 | '-qy', 56 | 'snapd' 57 | ]) 58 | }) 59 | 60 | test('ensureSnapd is a no-op if snapd is installed', async () => { 61 | expect.assertions(3) 62 | 63 | const accessMock = jest 64 | .spyOn(fs.promises, 'access') 65 | .mockImplementation( 66 | async ( 67 | filename: fs.PathLike, 68 | mode?: number | undefined 69 | ): Promise => {} 70 | ) 71 | const statMock = jest 72 | .spyOn(fs.promises, 'stat') 73 | .mockImplementation(async (filename: fs.PathLike): Promise => { 74 | const s = new fs.Stats() 75 | s.uid = 0 76 | s.gid = 0 77 | return s 78 | }) 79 | const execMock = jest 80 | .spyOn(exec, 'exec') 81 | .mockImplementation( 82 | async (program: string, args?: string[]): Promise => { 83 | return 0 84 | } 85 | ) 86 | 87 | await tools.ensureSnapd() 88 | 89 | expect(accessMock).toHaveBeenCalled() 90 | expect(statMock).toHaveBeenCalled() 91 | expect(execMock).not.toHaveBeenCalled() 92 | }) 93 | 94 | test('ensureSnapd fixes permissions on the root directory', async () => { 95 | expect.assertions(3) 96 | 97 | const accessMock = jest 98 | .spyOn(fs.promises, 'access') 99 | .mockImplementation( 100 | async ( 101 | filename: fs.PathLike, 102 | mode?: number | undefined 103 | ): Promise => {} 104 | ) 105 | const statMock = jest 106 | .spyOn(fs.promises, 'stat') 107 | .mockImplementation(async (filename: fs.PathLike): Promise => { 108 | const s = new fs.Stats() 109 | s.uid = 500 110 | s.gid = 0 111 | return s 112 | }) 113 | const execMock = jest 114 | .spyOn(exec, 'exec') 115 | .mockImplementation( 116 | async (program: string, args?: string[]): Promise => { 117 | return 0 118 | } 119 | ) 120 | 121 | await tools.ensureSnapd() 122 | 123 | expect(accessMock).toHaveBeenCalled() 124 | expect(statMock).toHaveBeenCalled() 125 | expect(execMock).toHaveBeenCalledWith('sudo', ['chown', 'root:root', '/']) 126 | }) 127 | 128 | test('ensureLXD installs the snap version of LXD if needed', async () => { 129 | expect.assertions(5) 130 | 131 | const accessMock = jest 132 | .spyOn(fs.promises, 'access') 133 | .mockImplementation( 134 | async ( 135 | filename: fs.PathLike, 136 | mode?: number | undefined 137 | ): Promise => { 138 | throw new Error('not found') 139 | } 140 | ) 141 | const execMock = jest 142 | .spyOn(exec, 'exec') 143 | .mockImplementation( 144 | async (program: string, args?: string[]): Promise => { 145 | return 0 146 | } 147 | ) 148 | 149 | await tools.ensureLXD() 150 | 151 | expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ 152 | 'groupadd', 153 | '--force', 154 | '--system', 155 | 'lxd' 156 | ]) 157 | expect(execMock).toHaveBeenNthCalledWith(2, 'sudo', [ 158 | 'usermod', 159 | '--append', 160 | '--groups', 161 | 'lxd', 162 | os.userInfo().username 163 | ]) 164 | expect(accessMock).toHaveBeenCalled() 165 | expect(execMock).toHaveBeenNthCalledWith(3, 'sudo', [ 166 | 'snap', 167 | 'install', 168 | 'lxd' 169 | ]) 170 | expect(execMock).toHaveBeenNthCalledWith(4, 'sudo', ['lxd', 'init', '--auto']) 171 | }) 172 | 173 | test('ensureLXD removes the apt version of LXD', async () => { 174 | expect.assertions(2) 175 | 176 | const accessMock = jest 177 | .spyOn(fs.promises, 'access') 178 | .mockImplementation( 179 | async ( 180 | filename: fs.PathLike, 181 | mode?: number | undefined 182 | ): Promise => { 183 | return 184 | } 185 | ) 186 | const execMock = jest 187 | .spyOn(exec, 'exec') 188 | .mockImplementation( 189 | async (program: string, args?: string[]): Promise => { 190 | return 0 191 | } 192 | ) 193 | 194 | await tools.ensureLXD() 195 | 196 | expect(accessMock).toHaveBeenCalled() 197 | expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ 198 | 'apt-get', 199 | 'remove', 200 | '-qy', 201 | 'lxd', 202 | 'lxd-client' 203 | ]) 204 | }) 205 | 206 | test('ensureLXD still calls "lxd init" if LXD is installed', async () => { 207 | expect.assertions(5) 208 | 209 | const accessMock = jest 210 | .spyOn(fs.promises, 'access') 211 | .mockImplementation( 212 | async ( 213 | filename: fs.PathLike, 214 | mode?: number | undefined 215 | ): Promise => { 216 | if (filename === '/snap/bin/lxd') { 217 | return 218 | } 219 | throw new Error('not found') 220 | } 221 | ) 222 | const execMock = jest 223 | .spyOn(exec, 'exec') 224 | .mockImplementation( 225 | async (program: string, args?: string[]): Promise => { 226 | return 0 227 | } 228 | ) 229 | 230 | await tools.ensureLXD() 231 | 232 | expect(accessMock).toHaveBeenCalled() 233 | expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ 234 | 'groupadd', 235 | '--force', 236 | '--system', 237 | 'lxd' 238 | ]) 239 | expect(execMock).toHaveBeenNthCalledWith(2, 'sudo', [ 240 | 'usermod', 241 | '--append', 242 | '--groups', 243 | 'lxd', 244 | os.userInfo().username 245 | ]) 246 | expect(execMock).toHaveBeenNthCalledWith(3, 'sudo', [ 247 | 'snap', 248 | 'refresh', 249 | 'lxd' 250 | ]) 251 | expect(execMock).toHaveBeenNthCalledWith(4, 'sudo', ['lxd', 'init', '--auto']) 252 | }) 253 | 254 | test('ensureSnapcraft installs Snapcraft if needed', async () => { 255 | expect.assertions(2) 256 | 257 | const accessMock = jest 258 | .spyOn(fs.promises, 'access') 259 | .mockImplementation( 260 | async ( 261 | filename: fs.PathLike, 262 | mode?: number | undefined 263 | ): Promise => { 264 | throw new Error('not found') 265 | } 266 | ) 267 | const execMock = jest 268 | .spyOn(exec, 'exec') 269 | .mockImplementation( 270 | async (program: string, args?: string[]): Promise => { 271 | return 0 272 | } 273 | ) 274 | 275 | await tools.ensureSnapcraft('edge') 276 | 277 | expect(accessMock).toHaveBeenCalled() 278 | expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ 279 | 'snap', 280 | 'install', 281 | '--channel', 282 | 'edge', 283 | '--classic', 284 | 'snapcraft' 285 | ]) 286 | }) 287 | 288 | test('ensureSnapcraft refreshes if Snapcraft is installed', async () => { 289 | expect.assertions(2) 290 | 291 | const accessMock = jest 292 | .spyOn(fs.promises, 'access') 293 | .mockImplementation( 294 | async ( 295 | filename: fs.PathLike, 296 | mode?: number | undefined 297 | ): Promise => { 298 | return 299 | } 300 | ) 301 | const execMock = jest 302 | .spyOn(exec, 'exec') 303 | .mockImplementation( 304 | async (program: string, args?: string[]): Promise => { 305 | return 0 306 | } 307 | ) 308 | 309 | await tools.ensureSnapcraft('edge') 310 | 311 | expect(accessMock).toHaveBeenCalled() 312 | expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ 313 | 'snap', 314 | 'refresh', 315 | '--channel', 316 | 'edge', 317 | '--classic', 318 | 'snapcraft' 319 | ]) 320 | }) 321 | 322 | test('ensureLXDNetwork sets up iptables and warns about Docker', async () => { 323 | expect.assertions(8) 324 | 325 | const infoMock = jest 326 | .spyOn(core, 'info') 327 | .mockImplementation((info: string) => {}) 328 | 329 | const execMock = jest 330 | .spyOn(exec, 'exec') 331 | .mockImplementation( 332 | async (program: string, args?: string[]): Promise => { 333 | if (args != undefined && args[1] == 'moby-runc') { 334 | return 0 335 | } else { 336 | return 1 337 | } 338 | } 339 | ) 340 | 341 | await tools.ensureLXDNetwork() 342 | 343 | expect(infoMock).toHaveBeenCalledWith( 344 | 'Installed docker related packages might interfere with LXD networking: moby-runc' 345 | ) 346 | expect(execMock).toHaveBeenNthCalledWith(1, 'dpkg', ['-l', 'moby-buildx'], { 347 | ignoreReturnCode: true, 348 | silent: true 349 | }) 350 | expect(execMock).toHaveBeenNthCalledWith(2, 'dpkg', ['-l', 'moby-engine'], { 351 | ignoreReturnCode: true, 352 | silent: true 353 | }) 354 | expect(execMock).toHaveBeenNthCalledWith(3, 'dpkg', ['-l', 'moby-cli'], { 355 | ignoreReturnCode: true, 356 | silent: true 357 | }) 358 | expect(execMock).toHaveBeenNthCalledWith(4, 'dpkg', ['-l', 'moby-compose'], { 359 | ignoreReturnCode: true, 360 | silent: true 361 | }) 362 | expect(execMock).toHaveBeenNthCalledWith( 363 | 5, 364 | 'dpkg', 365 | ['-l', 'moby-containerd'], 366 | {ignoreReturnCode: true, silent: true} 367 | ) 368 | expect(execMock).toHaveBeenNthCalledWith(6, 'dpkg', ['-l', 'moby-runc'], { 369 | ignoreReturnCode: true, 370 | silent: true 371 | }) 372 | expect(execMock).toHaveBeenNthCalledWith(7, 'sudo', [ 373 | 'iptables', 374 | '-P', 375 | 'FORWARD', 376 | 'ACCEPT' 377 | ]) 378 | }) 379 | 380 | test('ensureLXDNetwork sets up iptables and warns only about installed packages', async () => { 381 | expect.assertions(8) 382 | 383 | const infoMock = jest 384 | .spyOn(core, 'info') 385 | .mockImplementation((info: string) => {}) 386 | const execMock = jest 387 | .spyOn(exec, 'exec') 388 | .mockImplementation( 389 | async (program: string, args?: string[]): Promise => { 390 | return 0 391 | } 392 | ) 393 | 394 | await tools.ensureLXDNetwork() 395 | 396 | expect(infoMock).toHaveBeenCalledWith( 397 | 'Installed docker related packages might interfere with LXD networking: ' + 398 | 'moby-buildx,moby-engine,moby-cli,moby-compose,moby-containerd,moby-runc' 399 | ) 400 | expect(execMock).toHaveBeenNthCalledWith(1, 'dpkg', ['-l', 'moby-buildx'], { 401 | ignoreReturnCode: true, 402 | silent: true 403 | }) 404 | expect(execMock).toHaveBeenNthCalledWith(2, 'dpkg', ['-l', 'moby-engine'], { 405 | ignoreReturnCode: true, 406 | silent: true 407 | }) 408 | expect(execMock).toHaveBeenNthCalledWith(3, 'dpkg', ['-l', 'moby-cli'], { 409 | ignoreReturnCode: true, 410 | silent: true 411 | }) 412 | expect(execMock).toHaveBeenNthCalledWith(4, 'dpkg', ['-l', 'moby-compose'], { 413 | ignoreReturnCode: true, 414 | silent: true 415 | }) 416 | expect(execMock).toHaveBeenNthCalledWith( 417 | 5, 418 | 'dpkg', 419 | ['-l', 'moby-containerd'], 420 | {ignoreReturnCode: true, silent: true} 421 | ) 422 | expect(execMock).toHaveBeenNthCalledWith(6, 'dpkg', ['-l', 'moby-runc'], { 423 | ignoreReturnCode: true, 424 | silent: true 425 | }) 426 | expect(execMock).toHaveBeenNthCalledWith(7, 'sudo', [ 427 | 'iptables', 428 | '-P', 429 | 'FORWARD', 430 | 'ACCEPT' 431 | ]) 432 | }) 433 | --------------------------------------------------------------------------------