├── .eslintignore ├── .prettierignore ├── CHECKSUMS ├── 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 ├── .prettierrc.json ├── jest.config.js ├── src ├── state-helper.ts ├── channel-matrix.ts ├── argparser.ts ├── main.ts ├── tools.ts └── build.ts ├── .github ├── workflows │ ├── tag-latest.yml │ └── test.yml ├── FUNDING.yml └── actions │ └── fix-crun │ └── action.yml ├── tsconfig.json ├── LICENSE ├── CONTRIBUTING.md ├── package.json ├── __tests__ ├── channel-matrix.test.ts ├── tools.test.ts └── build.test.ts ├── .gitignore ├── .eslintrc.json ├── action.yml └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /CHECKSUMS: -------------------------------------------------------------------------------- 1 | 4f170aaa10d2ef02560cfb60b67ddfa1a83b1b4f7018227e9cb23a6af3955ec1 crun -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /src/state-helper.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | export const isPost = !!process.env['STATE_isPost'] 4 | export const tmpDir = process.env['STATE_tmpDir'] || '' 5 | 6 | export function setTmpDir(dir: string): void { 7 | core.saveState('tmpDir', dir) 8 | } 9 | 10 | if (!isPost) { 11 | core.saveState('isPost', 'true') 12 | } 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/tag-latest.yml: -------------------------------------------------------------------------------- 1 | name: "Tag latest release of Snapcraft multiarch action :zap:" 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | - edited 8 | 9 | jobs: 10 | run-tag-latest: 11 | runs-on: windows-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: Actions-R-Us/actions-tagger@latest 15 | with: 16 | publish_latest_tag: true 17 | prefer_branch_releases: false 18 | -------------------------------------------------------------------------------- /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 | meson-parameters: 18 | - --prefix=/ 19 | source: . 20 | build-packages: 21 | - meson 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 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: diddledani 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: diddledani 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: diddledani 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /src/channel-matrix.ts: -------------------------------------------------------------------------------- 1 | export function getChannel(base: string, channel: string): string { 2 | switch (base) { 3 | case 'core22': 4 | case 'core20': 5 | return channel 6 | case 'core18': 7 | if (channel.startsWith('5.x/')) { 8 | return channel 9 | } 10 | if (['stable', 'candidate', 'beta', 'edge'].includes(channel)) { 11 | return `5.x/${channel}` 12 | } 13 | case 'core': 14 | if (channel.startsWith('4.x/')) { 15 | return channel 16 | } 17 | if (['stable', 'candidate', 'beta', 'edge'].includes(channel)) { 18 | return `4.x/${channel}` 19 | } 20 | } 21 | 22 | throw new Error( 23 | `Snapcraft Channel '${channel}' is unsupported for builds targetting the '${base}' Base Snap.` 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /.github/actions/fix-crun/action.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 Markus Falb 2 | # GNU General Public License v3.0+ 3 | # see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt 4 | --- 5 | 6 | # https://github.com/actions/runner-images/issues/9425 7 | name: 'Fix crun' 8 | description: 'Fix crun because of incompatible kernel' 9 | 10 | inputs: 11 | checksums: 12 | description: 'The path to the CHECKSUM file' 13 | type: string 14 | required: true 15 | 16 | runs: 17 | using: composite 18 | steps: 19 | - name: patch crun 20 | shell: bash 21 | env: 22 | URI: https://github.com/containers/crun/releases/download/1.14.4/crun-1.14.4-linux-amd64 23 | CHECKSUMS: ${{ inputs.checksums }} 24 | run: | 25 | cd $(dirname "$CHECKSUMS") 26 | test -f "$(basename $CHECKSUMS)" 27 | curl -Lo crun "$URI" 28 | sha256sum -c "$(basename $CHECKSUMS)" 29 | sudo install crun /usr/bin/crun 30 | ... -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", /* 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 | "outDir": "./lib", /* Redirect output structure to the directory. */ 7 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 8 | "strict": true, /* Enable all strict type-checking options. */ 9 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 10 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 11 | }, 12 | "exclude": ["node_modules", "**/*.test.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2020 Daniel Llewellyn 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 | -------------------------------------------------------------------------------- /src/argparser.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | export function parseArgs(argumentsString: string): string[] { 4 | let param = '' 5 | let char: string | undefined 6 | const charArray: string[] = argumentsString.split('') 7 | let args: string[] = [] 8 | while ((char = charArray.shift())) { 9 | let subChar: string | undefined 10 | switch (char) { 11 | case '"': 12 | case "'": 13 | param += char 14 | while ((subChar = charArray.shift())) { 15 | if (subChar === char) { 16 | args = args.concat(param + subChar) 17 | param = '' 18 | break 19 | } else if (subChar) { 20 | param += subChar 21 | if (subChar === '\\') { 22 | param += charArray.shift() 23 | } 24 | } 25 | } 26 | break 27 | case '\\': 28 | param += char 29 | param += charArray.shift() 30 | break 31 | case ' ': 32 | if (param) { 33 | args = args.concat(param) 34 | } 35 | param = '' 36 | break 37 | default: 38 | param += char 39 | } 40 | } 41 | if (param) { 42 | args = args.concat(param) 43 | } 44 | return args 45 | } 46 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as os from 'os' 4 | import * as core from '@actions/core' 5 | import {SnapcraftBuilder} from './build' 6 | 7 | async function run(): Promise { 8 | try { 9 | if (os.platform() !== 'linux') { 10 | throw new Error(`Only supported on linux platform`) 11 | } 12 | 13 | const path = core.getInput('path') 14 | const usePodman = 15 | (core.getInput('use-podman') ?? 'true').toUpperCase() === 'TRUE' 16 | const buildInfo = 17 | (core.getInput('build-info') ?? 'true').toUpperCase() === 'TRUE' 18 | core.info(`Building Snapcraft project in "${path}"...`) 19 | const snapcraftChannel = core.getInput('snapcraft-channel') 20 | const snapcraftArgs = core.getInput('snapcraft-args') 21 | const architecture = core.getInput('architecture') 22 | const environment = core.getMultilineInput('environment') 23 | const store_auth = core.getInput('store-auth') 24 | 25 | const builder = new SnapcraftBuilder( 26 | path, 27 | buildInfo, 28 | snapcraftChannel, 29 | snapcraftArgs, 30 | architecture, 31 | environment, 32 | usePodman, 33 | store_auth 34 | ) 35 | await builder.build() 36 | const snap = await builder.outputSnap() 37 | core.setOutput('snap', snap) 38 | } catch (error) { 39 | if (error instanceof Error) { 40 | core.setFailed(error.message) 41 | } 42 | } 43 | } 44 | 45 | run() 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snapcraft-multiarch-build-action", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "A Github action that builds snapcraft projects for multiple architectures", 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": "run-s build format lint pack test" 15 | }, 16 | "pre-commit": [ 17 | "all" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/diddlesnaps/snapcraft-action.git" 22 | }, 23 | "keywords": [ 24 | "actions", 25 | "node", 26 | "setup" 27 | ], 28 | "author": "Dani Llewellyn", 29 | "license": "MIT", 30 | "dependencies": { 31 | "@actions/core": "^1.10.0", 32 | "@actions/exec": "^1.1.1" 33 | }, 34 | "devDependencies": { 35 | "@types/jest": "^29.2.0", 36 | "@types/js-yaml": "^4.0.5", 37 | "@types/node": "^16.18.0", 38 | "@typescript-eslint/parser": "^5.40.1", 39 | "@vercel/ncc": "^0.34.0", 40 | "eslint": "^8.26.0", 41 | "eslint-plugin-github": "^4.4.0", 42 | "eslint-plugin-jest": "^27.1.3", 43 | "jest": "^29.2.1", 44 | "jest-circus": "^29.2.1", 45 | "js-yaml": "^4.1.0", 46 | "npm-run-all2": "^6.0.2", 47 | "pre-commit": "^1.2.2", 48 | "prettier": "^2.7.1", 49 | "ts-jest": "^29.0.3", 50 | "typescript": "^4.8.4" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /__tests__/channel-matrix.test.ts: -------------------------------------------------------------------------------- 1 | import {getChannel} from '../src/channel-matrix' 2 | 3 | for (const [base, channel, expected] of [ 4 | ['core', 'stable', '4.x/stable'], 5 | ['core', 'candidate', '4.x/candidate'], 6 | ['core', 'beta', '4.x/beta'], 7 | ['core', 'edge', '4.x/edge'], 8 | ['core', '4.x/stable', '4.x/stable'], 9 | ['core', '4.x/candidate', '4.x/candidate'], 10 | ['core', '4.x/beta', '4.x/beta'], 11 | ['core', '4.x/edge', '4.x/edge'], 12 | ['core18', 'stable', '5.x/stable'], 13 | ['core18', 'candidate', '5.x/candidate'], 14 | ['core18', 'beta', '5.x/beta'], 15 | ['core18', 'edge', '5.x/edge'], 16 | ['core18', '4.x/stable', '4.x/stable'], 17 | ['core18', '4.x/candidate', '4.x/candidate'], 18 | ['core18', '4.x/beta', '4.x/beta'], 19 | ['core18', '4.x/edge', '4.x/edge'], 20 | ['core18', '5.x/stable', '5.x/stable'], 21 | ['core18', '5.x/candidate', '5.x/candidate'], 22 | ['core18', '5.x/beta', '5.x/beta'], 23 | ['core18', '5.x/edge', '5.x/edge'], 24 | ['core20', 'stable', 'stable'], 25 | ['core20', 'candidate', 'candidate'], 26 | ['core20', 'beta', 'beta'], 27 | ['core20', 'edge', 'edge'], 28 | ['core20', '4.x/stable', '4.x/stable'], 29 | ['core20', '4.x/candidate', '4.x/candidate'], 30 | ['core20', '4.x/beta', '4.x/beta'], 31 | ['core20', '4.x/edge', '4.x/edge'], 32 | ['core20', '5.x/stable', '5.x/stable'], 33 | ['core20', '5.x/candidate', '5.x/candidate'], 34 | ['core20', '5.x/beta', '5.x/beta'], 35 | ['core20', '5.x/edge', '5.x/edge'] 36 | ]) { 37 | test(`getChannel for '${base}' and '${channel}' returns '${expected}'`, () => { 38 | expect.assertions(1) 39 | expect(getChannel(base, channel)).toEqual(expected) 40 | }) 41 | } 42 | 43 | for (const [base, channel] of [ 44 | ['core', '5.x/stable'], 45 | ['core', '5.x/candidate'], 46 | ['core', '5.x/beta'], 47 | ['core', '5.x/edge'] 48 | ]) { 49 | test(`getChannel for '${base}' and '${channel}' throws an error`, () => { 50 | expect.assertions(1) 51 | expect(() => getChannel(base, channel)).toThrow() 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /.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 | 105 | test-projects/*/parts 106 | test-projects/*/stage 107 | test-projects/*/prime 108 | test-projects/*/*.snap 109 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as exec from '@actions/exec' 4 | import * as fs from 'fs' 5 | import * as path from 'path' 6 | import * as yaml from 'js-yaml' 7 | 8 | const dockerJson = '/etc/docker/daemon.json' 9 | 10 | async function haveFile(filePath: string): Promise { 11 | try { 12 | await fs.promises.access(filePath, fs.constants.R_OK) 13 | } catch (err) { 14 | return false 15 | } 16 | return true 17 | } 18 | 19 | export async function ensureDockerExperimental(): Promise { 20 | let json: {[key: string]: string | boolean} = {} 21 | if (await haveFile(dockerJson)) { 22 | json = JSON.parse( 23 | await fs.promises.readFile(dockerJson, {encoding: 'utf-8'}) 24 | ) 25 | } 26 | if (!('experimental' in json) || json['experimental'] !== true) { 27 | json['experimental'] = true 28 | await exec.exec('bash', [ 29 | '-c', 30 | `echo '${JSON.stringify(json)}' | sudo tee /etc/docker/daemon.json` 31 | ]) 32 | await exec.exec('sudo', ['systemctl', 'restart', 'docker']) 33 | } 34 | } 35 | 36 | async function findSnapcraftYaml(projectRoot: string): Promise { 37 | const filePaths = [ 38 | path.join(projectRoot, 'snap', 'snapcraft.yaml'), 39 | path.join(projectRoot, 'snapcraft.yaml'), 40 | path.join(projectRoot, '.snapcraft.yaml') 41 | ] 42 | for (const filePath of filePaths) { 43 | if (await haveFile(filePath)) { 44 | return filePath 45 | } 46 | } 47 | throw new Error('Cannot find snapcraft.yaml') 48 | } 49 | 50 | interface SnapcraftYaml { 51 | base: string | undefined 52 | 'build-base': string | undefined 53 | [key: string]: string | number | Object | string[] | undefined 54 | } 55 | export async function detectBase(projectRoot: string): Promise { 56 | const snapcraftFile = await findSnapcraftYaml(projectRoot) 57 | const snapcraftYaml: SnapcraftYaml = yaml.load( 58 | await fs.promises.readFile(snapcraftFile, 'utf-8'), 59 | {filename: snapcraftFile} 60 | ) as SnapcraftYaml 61 | if (snapcraftYaml === undefined) { 62 | throw new Error('Cannot parse snapcraft.yaml') 63 | } 64 | if (snapcraftYaml['build-base']) { 65 | return snapcraftYaml['build-base'] 66 | } 67 | if (snapcraftYaml.base) { 68 | return snapcraftYaml.base 69 | } 70 | return 'core' 71 | } 72 | 73 | export async function detectCGroupsV1(): Promise { 74 | const cgroups = await fs.promises.readFile('/proc/1/cgroup', 'utf-8') 75 | return cgroups.includes('cpu,cpuacct') 76 | } 77 | -------------------------------------------------------------------------------- /.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 | "no-fallthrough": "off", 16 | "@typescript-eslint/no-unused-vars": "error", 17 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}], 18 | "@typescript-eslint/no-require-imports": "error", 19 | "@typescript-eslint/array-type": "error", 20 | "@typescript-eslint/await-thenable": "error", 21 | "@typescript-eslint/ban-ts-comment": "error", 22 | "camelcase": "off", 23 | "@typescript-eslint/consistent-type-assertions": ["error", {"assertionStyle": "as", "objectLiteralTypeAssertions": "never"}], 24 | "@typescript-eslint/consistent-type-definitions": ["error", "interface"], 25 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}], 26 | "@typescript-eslint/func-call-spacing": ["error", "never"], 27 | "@typescript-eslint/naming-convention": ["error", { "selector": "property", "format": ["camelCase"], "filter": { "regex": "[- ]", "match": false } } ], 28 | "@typescript-eslint/no-array-constructor": "error", 29 | "@typescript-eslint/no-empty-interface": "error", 30 | "@typescript-eslint/no-explicit-any": "error", 31 | "@typescript-eslint/no-extraneous-class": "error", 32 | "@typescript-eslint/no-for-in-array": "error", 33 | "@typescript-eslint/no-inferrable-types": "error", 34 | "@typescript-eslint/no-misused-new": "error", 35 | "@typescript-eslint/no-namespace": "error", 36 | "@typescript-eslint/no-non-null-assertion": "warn", 37 | "@typescript-eslint/no-unnecessary-qualifier": "error", 38 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 39 | "@typescript-eslint/no-useless-constructor": "error", 40 | "@typescript-eslint/no-var-requires": "error", 41 | "@typescript-eslint/prefer-for-of": "warn", 42 | "@typescript-eslint/prefer-function-type": "warn", 43 | "@typescript-eslint/prefer-includes": "error", 44 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 45 | "@typescript-eslint/promise-function-async": "error", 46 | "@typescript-eslint/require-array-sort-compare": "error", 47 | "@typescript-eslint/restrict-plus-operands": "error", 48 | "semi": "off", 49 | "@typescript-eslint/semi": ["error", "never"], 50 | "@typescript-eslint/type-annotation-spacing": "error", 51 | "@typescript-eslint/unbound-method": "error" 52 | }, 53 | "env": { 54 | "node": true, 55 | "es6": true, 56 | "jest/globals": true 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Snapcraft Multiarch Build' 2 | description: 'Build a Snapcraft project for multiple architectures' 3 | author: 'Dani Llewellyn' 4 | branding: 5 | icon: 'package' 6 | color: 'orange' 7 | inputs: 8 | path: 9 | description: > 10 | The location of the Snapcraft project. 11 | 12 | Defaults to the base of the repository 13 | default: '.' 14 | required: true 15 | use-podman: 16 | description: > 17 | Use Podman instead of Docker (experimental) 18 | 19 | You cannot build for foreign architectures via binfmt using Podman. 20 | Therefore, this is best used when you are using a self-hosted runner 21 | that has the correct target architecture. Using the GitHub hosted 22 | runners you can only build for x86_64/amd64 with Podman. 23 | default: 'false' 24 | required: false 25 | build-info: 26 | description: > 27 | Whether to include build information in the resulting snap. 28 | 29 | This will add snap/manifest.yaml and snap/snapcraft.yaml files 30 | to the snap. The Snap Store can use this information to detect 31 | snaps with security vulnerabilities introduced through 32 | stage-packages. 33 | 34 | Proprietary applications may want to disable this due to 35 | the information leakage. 36 | default: 'true' 37 | required: false 38 | snapcraft-channel: 39 | description: > 40 | The Snapcraft channel to use 41 | 42 | By default, the action will use the stable version of Snapcraft 43 | to build the project. This parameter can be used to instead 44 | select a different channel such as beta, candidate, or edge. 45 | default: 'stable' 46 | required: false 47 | snapcraft-args: 48 | description: > 49 | Additional arguments to pass to Snapcraft 50 | 51 | Some experimental Snapcraft features are disabled by default and 52 | must be turned on via a `--enable-experimental-*` command line 53 | argument. This parameter can be used to turn on such features. 54 | required: false 55 | architecture: 56 | description: > 57 | The architecture to build with Snapcraft 58 | 59 | Snap Packages run on multiple CPU architectures. This parameter 60 | allows you to build for any of the supported architectures. It 61 | accepts the same values you would use in the `build-on` option 62 | in your `snapcraft.yaml` when building via the Build Service. 63 | You may only specify one architecture at a time so it's best to 64 | combine this with the build matrix feature of GitHub Actions. 65 | default: 'amd64' 66 | required: true 67 | environment: 68 | description: > 69 | Environment to pass to Snapcraft 70 | 71 | Add environment variables to the Snapcraft build context. Each 72 | variable needs to be specified on a separate line. For example: 73 | 74 | environment: | 75 | FOO=bar 76 | BAZ=qux 77 | required: false 78 | store-auth: 79 | description: > 80 | The Snap Store authentication token 81 | 82 | This token is used to authenticate with the Snap Store when 83 | uploading the snap. It can be obtained by running `snapcraft 84 | export-login --snaps -` and copying the output. 85 | required: false 86 | outputs: 87 | snap: 88 | description: 'The file name of the resulting snap.' 89 | runs: 90 | using: node20 91 | main: 'dist/index.js' 92 | -------------------------------------------------------------------------------- /.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 | 9 | jobs: 10 | unit: # make sure build/ci work properly 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Install crun 15 | uses: ./.github/actions/fix-crun 16 | with: 17 | checksums: CHECKSUMS 18 | - run: | 19 | npm install 20 | npm run all 21 | 22 | docker-integration: # make sure the action works on a clean machine without building 23 | strategy: 24 | matrix: 25 | base: 26 | - core18 27 | - core20 28 | - core22 29 | arch: 30 | - '' 31 | - amd64 32 | - armhf 33 | - arm64 34 | - ppc64el 35 | runner: 36 | - ubuntu-latest 37 | include: 38 | - base: core 39 | arch: '' 40 | runner: ubuntu-20.04 41 | - base: core 42 | arch: i386 43 | runner: ubuntu-20.04 44 | - base: core 45 | arch: amd64 46 | runner: ubuntu-20.04 47 | - base: core 48 | arch: armhf 49 | runner: ubuntu-20.04 50 | - base: core 51 | arch: arm64 52 | runner: ubuntu-20.04 53 | - base: core18 54 | arch: i386 55 | runner: ubuntu-latest 56 | runs-on: ${{ matrix.runner }} 57 | steps: 58 | - uses: docker/setup-qemu-action@v2 59 | - uses: actions/checkout@v4 60 | - name: Install crun 61 | uses: ./.github/actions/fix-crun 62 | with: 63 | checksums: CHECKSUMS 64 | - uses: ./ 65 | id: snapcraft 66 | with: 67 | path: './test-projects/${{ matrix.base }}' 68 | architecture: ${{ matrix.arch }} 69 | 70 | podman-integration: # make sure the action works on a clean machine without building 71 | strategy: 72 | matrix: 73 | base: 74 | - core20 75 | - core22 76 | arch: 77 | - name: '' 78 | runner: ubuntu-latest 79 | - name: '' 80 | runner: [self-hosted, linux, ARM64] 81 | - name: amd64 82 | runner: ubuntu-latest 83 | - name: arm64 84 | runner: [self-hosted, linux, ARM64] 85 | include: 86 | - base: core 87 | arch: 88 | name: '' 89 | runner: ubuntu-20.04 90 | - base: core 91 | arch: 92 | name: i386 93 | runner: ubuntu-20.04 94 | - base: core 95 | arch: 96 | name: amd64 97 | runner: ubuntu-20.04 98 | - base: core18 99 | arch: 100 | name: i386 101 | runner: ubuntu-latest 102 | - base: core18 103 | arch: 104 | name: amd64 105 | runner: ubuntu-latest 106 | runs-on: ${{ matrix.arch.runner }} 107 | steps: 108 | - uses: docker/setup-qemu-action@v2 109 | - uses: actions/checkout@v3 110 | - uses: ./ 111 | id: snapcraft 112 | with: 113 | path: './test-projects/${{ matrix.base }}' 114 | architecture: ${{ matrix.arch.name }} 115 | use-podman: 'true' 116 | -------------------------------------------------------------------------------- /__tests__/tools.test.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as fs from 'fs' 4 | import * as exec from '@actions/exec' 5 | import * as tools from '../src/tools' 6 | 7 | afterEach(() => { 8 | jest.restoreAllMocks() 9 | }) 10 | 11 | test('ensureDockerExperimental is no-op if experimental already set', async () => { 12 | expect.assertions(3) 13 | 14 | const accessMock = jest 15 | .spyOn(fs.promises, 'access') 16 | .mockImplementation( 17 | async ( 18 | filename: fs.PathLike, 19 | mode?: number | undefined 20 | ): Promise => {} 21 | ) 22 | const readMock = jest 23 | .spyOn(fs.promises, 'readFile') 24 | .mockImplementation( 25 | async (filename: fs.PathLike | fs.promises.FileHandle): Promise => 26 | Buffer.from(`{"experimental": true}`) 27 | ) 28 | const execMock = jest 29 | .spyOn(exec, 'exec') 30 | .mockImplementation( 31 | async (program: string, args?: string[]): Promise => { 32 | return 0 33 | } 34 | ) 35 | 36 | await tools.ensureDockerExperimental() 37 | 38 | expect(accessMock).toHaveBeenCalled() 39 | expect(readMock).toHaveBeenCalled() 40 | expect(execMock).not.toHaveBeenCalled() 41 | }) 42 | 43 | test("ensureDockerExperimental sets experimental mode and restarts docker if configuration file doesn't exist", async () => { 44 | expect.assertions(3) 45 | 46 | const accessMock = jest 47 | .spyOn(fs.promises, 'access') 48 | .mockImplementation( 49 | async ( 50 | filename: fs.PathLike, 51 | mode?: number | undefined 52 | ): Promise => { 53 | throw new Error('not found') 54 | } 55 | ) 56 | const execMock = jest 57 | .spyOn(exec, 'exec') 58 | .mockImplementation( 59 | async (program: string, args?: string[]): Promise => { 60 | return 0 61 | } 62 | ) 63 | 64 | await tools.ensureDockerExperimental() 65 | 66 | expect(accessMock).toHaveBeenCalled() 67 | expect(execMock).toHaveBeenNthCalledWith(1, 'bash', [ 68 | '-c', 69 | `echo '{\"experimental\":true}' | sudo tee /etc/docker/daemon.json` 70 | ]) 71 | expect(execMock).toHaveBeenNthCalledWith(2, 'sudo', [ 72 | 'systemctl', 73 | 'restart', 74 | 'docker' 75 | ]) 76 | }) 77 | 78 | test('ensureDockerExperimental sets experimental mode and restarts docker if not already set', async () => { 79 | expect.assertions(4) 80 | 81 | const accessMock = jest 82 | .spyOn(fs.promises, 'access') 83 | .mockImplementation( 84 | async ( 85 | filename: fs.PathLike, 86 | mode?: number | undefined 87 | ): Promise => {} 88 | ) 89 | const readMock = jest 90 | .spyOn(fs.promises, 'readFile') 91 | .mockImplementation( 92 | async (filename: fs.PathLike | fs.promises.FileHandle): Promise => 93 | Buffer.from(`{}`) 94 | ) 95 | const execMock = jest 96 | .spyOn(exec, 'exec') 97 | .mockImplementation( 98 | async (program: string, args?: string[]): Promise => { 99 | return 0 100 | } 101 | ) 102 | 103 | await tools.ensureDockerExperimental() 104 | 105 | expect(accessMock).toHaveBeenCalled() 106 | expect(readMock).toHaveBeenCalled() 107 | expect(execMock).toHaveBeenNthCalledWith(1, 'bash', [ 108 | '-c', 109 | `echo '{\"experimental\":true}' | sudo tee /etc/docker/daemon.json` 110 | ]) 111 | expect(execMock).toHaveBeenNthCalledWith(2, 'sudo', [ 112 | 'systemctl', 113 | 'restart', 114 | 'docker' 115 | ]) 116 | }) 117 | 118 | test('ensureDockerExperimental sets experimental mode and restarts docker if explicitly disabled', async () => { 119 | expect.assertions(4) 120 | 121 | const accessMock = jest 122 | .spyOn(fs.promises, 'access') 123 | .mockImplementation( 124 | async ( 125 | filename: fs.PathLike, 126 | mode?: number | undefined 127 | ): Promise => {} 128 | ) 129 | const readMock = jest 130 | .spyOn(fs.promises, 'readFile') 131 | .mockImplementation( 132 | async (filename: fs.PathLike | fs.promises.FileHandle): Promise => 133 | Buffer.from(`{"experimental": false}`) 134 | ) 135 | const execMock = jest 136 | .spyOn(exec, 'exec') 137 | .mockImplementation( 138 | async (program: string, args?: string[]): Promise => { 139 | return 0 140 | } 141 | ) 142 | 143 | await tools.ensureDockerExperimental() 144 | 145 | expect(accessMock).toHaveBeenCalled() 146 | expect(readMock).toHaveBeenCalled() 147 | expect(execMock).toHaveBeenNthCalledWith(1, 'bash', [ 148 | '-c', 149 | `echo '{\"experimental\":true}' | sudo tee /etc/docker/daemon.json` 150 | ]) 151 | expect(execMock).toHaveBeenNthCalledWith(2, 'sudo', [ 152 | 'systemctl', 153 | 'restart', 154 | 'docker' 155 | ]) 156 | }) 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | snapcraft-multiarch-build-action status 3 |

4 | 5 | # Snapcraft Multiarch 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@v3 17 | - uses: diddlesnaps/snapcraft-multiarch-action@v1 18 | ``` 19 | 20 | This will run `snapcraft` using the [snapcraft-container](https://hub.docker.com/r/diddledani/snapcraft) 21 | 22 | On success, the action will set the `snap` output parameter to the 23 | path of the built snap. This can be used to save it as an artifact of 24 | the workflow: 25 | 26 | ```yaml 27 | ... 28 | - uses: diddlesnaps/snapcraft-multiarch-action@v1 29 | id: snapcraft 30 | - uses: actions/upload-artifact@v2 31 | with: 32 | name: snap 33 | path: ${{ steps.snapcraft.outputs.snap }} 34 | ``` 35 | 36 | Alternatively, it could be used to perform further testing on the built snap: 37 | 38 | ```yaml 39 | - run: | 40 | sudo snap install --dangerous ${{ steps.snapcraft.outputs.snap }} 41 | # do something with the snap 42 | ``` 43 | 44 | The action can also be chained with 45 | [`snapcore/action-publish@v1`](https://github.com/snapcore/action-publish) 46 | to automatically publish builds to the Snap Store. 47 | 48 | 49 | ## Multiple architectures support 50 | 51 | This action supports building for multiple architectures using 52 | the GitHub Actions matrix feature. 53 | 54 | ```yaml 55 | jobs: 56 | build: 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | platform: 61 | - i386 62 | - amd64 63 | - armhf 64 | - arm64 65 | - ppc64el 66 | - s390x 67 | steps: 68 | - uses: actions/checkout@v3 69 | - uses: docker/setup-qemu-action@v1 70 | - uses: diddlesnaps/snapcraft-multiarch-action@v1 71 | with: 72 | architecture: ${{ matrix.platform }} 73 | ``` 74 | 75 | notes 76 | ----- 77 | 78 | * `s390x` is broken at the moment. 79 | * Builds for `core20`, and later, do not support `i386` architecture because Ubuntu has dropped support for `i386` in Ubuntu 20.04 and later. 80 | * `core` builds do not support `s390x` architecture because Ubuntu does not have support for `s390x` before Ubuntu 18.04. 81 | 82 | ## Action inputs 83 | 84 | ### `path` 85 | 86 | If your Snapcraft project is not located in the root of the workspace, 87 | you can specify an alternative location via the `path` input 88 | parameter: 89 | 90 | ```yaml 91 | ... 92 | - uses: diddlesnaps/snapcraft-multiarch-action@v1 93 | with: 94 | path: path-to-snapcraft-project 95 | ``` 96 | 97 | ### `use-podman` 98 | 99 | By default, this action will use Docker. If you are running a 100 | self-hosted GitHub Actions Runner then you might prefer to use Podman 101 | instead. This switch allows you to request experimental support for 102 | Podman to be used. 103 | 104 | Note that when using Podman your build cannot target a CPU architecture 105 | that is different to the host your runner is based upon. This is a 106 | limitation of Podman. For example, you will need an ARM64 host to build 107 | an ARM64 Snap Package, when using Podman builds. Multiarch builds, where 108 | you build an i386 Snap on an AMD64 host or an ARMHF Snap on an ARM64 109 | host, might be possible but are untested - your mileage might vary. 110 | 111 | Set `use-podman` to `true` to use Podman instead of Docker. 112 | 113 | ### `build-info` 114 | 115 | By default, the action will tell Snapcraft to include information 116 | about the build in the resulting snap, in the form of the 117 | `snap/snapcraft.yaml` and `snap/manifest.yaml` files. Among other 118 | things, these are used by the Snap Store's automatic security 119 | vulnerability scanner to check whether your snap includes files from 120 | vulnerable versions of Ubuntu packages. 121 | 122 | This can be turned off by setting the `build-info` parameter to 123 | `false`. 124 | 125 | ### `snapcraft-channel` 126 | 127 | By default, the action will install Snapcraft from the stable 128 | channel. If your project relies on a feature not found in the stable 129 | version of Snapcraft, then the `snapcraft-channel` parameter can be 130 | used to select a different channel. 131 | 132 | ### `snapcraft-args` 133 | 134 | The `snapcraft-args` parameter can be used to pass additional 135 | arguments to Snapcraft. This is primarily intended to allow the use 136 | of experimental features by passing `--enable-experimental-*` 137 | arguments to Snapcraft. 138 | 139 | ### `architecture` 140 | 141 | By default, the action will build for AMD64. You may use this parameter 142 | to indicate an alternative architecture from any of those supported by 143 | the `snapcraft` utility. At the time of writing the supported 144 | architectures are `amd64`, `i386`, `arm64`, `armhf`, `ppc64el` and `s390x`. 145 | This is most-useful when used with GitHub Actions' `matrix` feature. 146 | 147 | ### `environment` 148 | 149 | Add environment variables to the Snapcraft build context. Each 150 | variable needs to be specified on a separate line. For example: 151 | 152 | ```yaml 153 | with: 154 | environment: | 155 | FOO=bar 156 | BAZ=qux 157 | ``` 158 | ### `store-auth` 159 | 160 | Set the `SNAPCRAFT_STORE_CREDENTIALS` environment variable. This 161 | is useful when using the `snapcraft push` command. 162 | 163 | You should not save the token into the yaml file directly, but use 164 | the GitHub Actions secrets feature: 165 | 166 | ```yaml 167 | with: 168 | store-auth: ${{ secrets.STORE_AUTH }} 169 | ``` -------------------------------------------------------------------------------- /src/build.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 core from '@actions/core' 8 | import * as exec from '@actions/exec' 9 | import * as tools from './tools' 10 | import {parseArgs} from './argparser' 11 | import {getChannel} from './channel-matrix' 12 | 13 | interface ImageInfo { 14 | 'build-request-id'?: string 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 | } else if (!path.isAbsolute(p)) { 24 | p = path.join(process.cwd(), p) 25 | } 26 | return p 27 | } 28 | 29 | export const platforms: {[key: string]: string} = { 30 | i386: 'linux/386', 31 | amd64: 'linux/amd64', 32 | armhf: 'linux/arm/v7', 33 | arm64: 'linux/arm64', 34 | ppc64el: 'linux/ppc64le', 35 | s390x: 'linux/s390x' 36 | } 37 | 38 | export class SnapcraftBuilder { 39 | projectRoot: string 40 | includeBuildInfo: boolean 41 | snapcraftChannel: string 42 | snapcraftArgs: string[] 43 | architecture: string 44 | environment: {[key: string]: string} 45 | usePodman: boolean 46 | storeAuth: string 47 | 48 | constructor( 49 | projectRoot: string, 50 | includeBuildInfo: boolean, 51 | snapcraftChannel: string, 52 | snapcraftArgs: string, 53 | architecture: string, 54 | environment: string[], 55 | usePodman: boolean, 56 | storeAuth: string 57 | ) { 58 | this.projectRoot = expandHome(projectRoot) 59 | this.includeBuildInfo = includeBuildInfo 60 | this.snapcraftChannel = snapcraftChannel 61 | this.snapcraftArgs = parseArgs(snapcraftArgs) 62 | this.architecture = architecture 63 | this.usePodman = usePodman 64 | this.storeAuth = storeAuth 65 | 66 | const envKV: {[key: string]: string} = {} 67 | for (const env of environment) { 68 | const [key, value] = env.split('=', 2) 69 | envKV[key] = value 70 | } 71 | this.environment = envKV 72 | } 73 | 74 | async build(): Promise { 75 | if (!this.usePodman) { 76 | await tools.ensureDockerExperimental() 77 | } 78 | const base = await tools.detectBase(this.projectRoot) 79 | 80 | if (!['core', 'core18', 'core20', 'core22'].includes(base)) { 81 | throw new Error( 82 | `Your build requires a base that this tool does not support (${base}). 'base' or 'build-base' in your 'snapcraft.yaml' must be one of 'core', 'core18' or 'core20'.` 83 | ) 84 | } 85 | 86 | if (base === 'core' && !(await tools.detectCGroupsV1())) { 87 | throw new Error( 88 | `Your build specified 'core' as the base, but your system is using cgroups v2. 'core' does not support cgroups v2. Please use 'core18' or later or an older Linux distribution that uses CGroups version 1 instead.` 89 | ) 90 | } 91 | 92 | const imageInfo: ImageInfo = { 93 | // eslint-disable-next-line @typescript-eslint/naming-convention 94 | build_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` 95 | } 96 | // Copy and update environment to pass to snapcraft 97 | const env: {[key: string]: string} = this.environment 98 | env['SNAPCRAFT_IMAGE_INFO'] = JSON.stringify(imageInfo) 99 | if (this.includeBuildInfo) { 100 | env['SNAPCRAFT_BUILD_INFO'] = '1' 101 | } 102 | if (this.snapcraftChannel !== '') { 103 | env['USE_SNAPCRAFT_CHANNEL'] = getChannel(base, this.snapcraftChannel) 104 | } 105 | if (this.storeAuth !== '') { 106 | env['SNAPCRAFT_STORE_CREDENTIALS'] = this.storeAuth 107 | } 108 | 109 | let dockerArgs: string[] = [] 110 | let pullArgs: string[] = [] 111 | if (this.architecture in platforms) { 112 | dockerArgs = dockerArgs.concat('--platform', platforms[this.architecture]) 113 | pullArgs = pullArgs.concat('--platform', platforms[this.architecture]) 114 | } 115 | 116 | for (const key in env) { 117 | dockerArgs = dockerArgs.concat('--env', `${key}=${env[key]}`) 118 | } 119 | 120 | let command = 'docker' 121 | let containerImage = `diddledani/snapcraft:${base}` 122 | if (this.usePodman) { 123 | command = 'sudo podman' 124 | containerImage = `docker.io/${containerImage}` 125 | dockerArgs = dockerArgs.concat('--systemd', 'always') 126 | } 127 | 128 | await exec.exec(command, ['pull', ...pullArgs, containerImage], { 129 | cwd: this.projectRoot 130 | }) 131 | 132 | await exec.exec( 133 | command, 134 | [ 135 | 'run', 136 | '--rm', 137 | '--tty', 138 | '--privileged', 139 | '--volume', 140 | `${this.projectRoot}:/data`, 141 | '--workdir', 142 | '/data', 143 | ...dockerArgs, 144 | containerImage, 145 | 'snapcraft', 146 | ...this.snapcraftArgs 147 | ], 148 | { 149 | cwd: this.projectRoot 150 | } 151 | ) 152 | } 153 | 154 | // This wrapper is for the benefit of the tests, due to the crazy 155 | // typing of fs.promises.readdir() 156 | async _readdir(dir: string): Promise { 157 | return await fs.promises.readdir(dir) 158 | } 159 | 160 | async outputSnap(): Promise { 161 | const workspace = process.env['GITHUB_WORKSPACE'] ?? process.cwd() 162 | 163 | const files = await this._readdir(this.projectRoot) 164 | const snaps = files.filter(name => name.endsWith('.snap')) 165 | 166 | if (snaps.length === 0) { 167 | throw new Error('No snap files produced by build') 168 | } 169 | if (snaps.length > 1) { 170 | core.warning(`Multiple snaps found in ${this.projectRoot}`) 171 | } 172 | const snap = path.join(this.projectRoot, snaps[0]).replace(workspace, '.') 173 | await exec.exec('sudo', ['chown', process.getuid().toString(), snap]) 174 | return snap 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /__tests__/build.test.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as os from 'os' 4 | import * as path from 'path' 5 | import * as process from 'process' 6 | import * as core from '@actions/core' 7 | import * as exec from '@actions/exec' 8 | import * as build from '../src/build' 9 | import * as tools from '../src/tools' 10 | 11 | const default_base = 'core22' 12 | 13 | afterEach(() => { 14 | jest.restoreAllMocks() 15 | }) 16 | 17 | test('SnapcraftBuilder expands tilde in project root', () => { 18 | let builder = new build.SnapcraftBuilder( 19 | '~', 20 | true, 21 | 'stable', 22 | '', 23 | '', 24 | [], 25 | false, 26 | '' 27 | ) 28 | expect(builder.projectRoot).toBe(os.homedir()) 29 | 30 | builder = new build.SnapcraftBuilder( 31 | '~/foo/bar', 32 | true, 33 | 'stable', 34 | '', 35 | '', 36 | [], 37 | false, 38 | '' 39 | ) 40 | expect(builder.projectRoot).toBe(path.join(os.homedir(), 'foo/bar')) 41 | }) 42 | 43 | let matrix: [string, string, string][] = [] 44 | for (const base of ['core', 'core18', 'core20', 'core22']) { 45 | for (const arch of [ 46 | '', 47 | 'amd64', 48 | 'arm64', 49 | 'armhf', 50 | 'i386', 51 | 'ppc64el', 52 | 's390x' 53 | ]) { 54 | let channel = '' 55 | switch (base) { 56 | case 'core': 57 | channel = '4.x/stable' 58 | break 59 | case 'core18': 60 | channel = '5.x/stable' 61 | break 62 | default: 63 | channel = 'stable' 64 | } 65 | matrix.push([base, arch, channel]) 66 | } 67 | } 68 | for (const [base, arch, channel] of matrix) { 69 | test(`SnapcraftBuilder.build runs a snap build using Docker with base: ${base}; and arch: ${arch}`, async () => { 70 | expect.assertions(5) 71 | 72 | const ensureDockerExperimentalMock = jest 73 | .spyOn(tools, 'ensureDockerExperimental') 74 | .mockImplementation(async (): Promise => Promise.resolve()) 75 | const detectBaseMock = jest 76 | .spyOn(tools, 'detectBase') 77 | .mockImplementation( 78 | async (projectRoot: string): Promise => Promise.resolve(base) 79 | ) 80 | const detectCGroupsV1Mock = jest 81 | .spyOn(tools, 'detectCGroupsV1') 82 | .mockImplementation(async (): Promise => Promise.resolve(true)) 83 | const execMock = jest 84 | .spyOn(exec, 'exec') 85 | .mockImplementation( 86 | async (program: string, args?: string[]): Promise => 0 87 | ) 88 | process.env['GITHUB_REPOSITORY'] = 'user/repo' 89 | process.env['GITHUB_RUN_ID'] = '42' 90 | 91 | const projectDir = 'project-root' 92 | const builder = new build.SnapcraftBuilder( 93 | projectDir, 94 | true, 95 | 'stable', 96 | '', 97 | arch, 98 | [], 99 | false, 100 | '' 101 | ) 102 | await builder.build() 103 | 104 | let platform: string[] = [] 105 | if (arch && arch in build.platforms) { 106 | platform = ['--platform', build.platforms[arch]] 107 | } 108 | 109 | expect(ensureDockerExperimentalMock).toHaveBeenCalled() 110 | expect(detectBaseMock).toHaveBeenCalled() 111 | if (base === 'core') { 112 | expect(detectCGroupsV1Mock).toHaveBeenCalled() 113 | } else { 114 | expect(detectCGroupsV1Mock).not.toHaveBeenCalled() 115 | } 116 | expect(execMock).toHaveBeenNthCalledWith( 117 | 1, 118 | 'docker', 119 | ['pull', ...platform, `diddledani/snapcraft:${base}`], 120 | expect.anything() 121 | ) 122 | expect(execMock).toHaveBeenNthCalledWith( 123 | 2, 124 | 'docker', 125 | [ 126 | 'run', 127 | '--rm', 128 | '--tty', 129 | '--privileged', 130 | '--volume', 131 | `${process.cwd()}/${projectDir}:/data`, 132 | '--workdir', 133 | '/data', 134 | ...platform, 135 | '--env', 136 | `SNAPCRAFT_IMAGE_INFO={"build_url":"https://github.com/user/repo/actions/runs/42"}`, 137 | '--env', 138 | 'SNAPCRAFT_BUILD_INFO=1', 139 | '--env', 140 | `USE_SNAPCRAFT_CHANNEL=${channel}`, 141 | `diddledani/snapcraft:${base}`, 142 | 'snapcraft' 143 | ], 144 | { 145 | cwd: `${process.cwd()}/${projectDir}` 146 | } 147 | ) 148 | }) 149 | 150 | test(`SnapcraftBuilder.build runs a snap build using Podman with base: ${base}; and arch: ${arch}`, async () => { 151 | expect.assertions(5) 152 | const ensureDockerExperimentalMock = jest 153 | .spyOn(tools, 'ensureDockerExperimental') 154 | .mockImplementation(async (): Promise => Promise.resolve()) 155 | const detectBaseMock = jest 156 | .spyOn(tools, 'detectBase') 157 | .mockImplementation( 158 | async (projectRoot: string): Promise => Promise.resolve(base) 159 | ) 160 | const detectCGroupsV1Mock = jest 161 | .spyOn(tools, 'detectCGroupsV1') 162 | .mockImplementation(async (): Promise => Promise.resolve(true)) 163 | const execMock = jest 164 | .spyOn(exec, 'exec') 165 | .mockImplementation( 166 | async (program: string, args?: string[]): Promise => 0 167 | ) 168 | process.env['GITHUB_REPOSITORY'] = 'user/repo' 169 | process.env['GITHUB_RUN_ID'] = '42' 170 | 171 | const projectDir = 'project-root' 172 | const builder = new build.SnapcraftBuilder( 173 | projectDir, 174 | true, 175 | 'stable', 176 | '', 177 | arch, 178 | [], 179 | true, 180 | '' 181 | ) 182 | await builder.build() 183 | 184 | let platform: string[] = [] 185 | if (arch && arch in build.platforms) { 186 | platform = ['--platform', build.platforms[arch]] 187 | } 188 | 189 | expect(ensureDockerExperimentalMock).not.toHaveBeenCalled() 190 | expect(detectBaseMock).toHaveBeenCalled() 191 | if (base === 'core') { 192 | expect(detectCGroupsV1Mock).toHaveBeenCalled() 193 | } else { 194 | expect(detectCGroupsV1Mock).not.toHaveBeenCalled() 195 | } 196 | expect(execMock).toHaveBeenNthCalledWith( 197 | 1, 198 | 'sudo podman', 199 | ['pull', ...platform, `docker.io/diddledani/snapcraft:${base}`], 200 | expect.anything() 201 | ) 202 | expect(execMock).toHaveBeenNthCalledWith( 203 | 2, 204 | 'sudo podman', 205 | [ 206 | 'run', 207 | '--rm', 208 | '--tty', 209 | '--privileged', 210 | '--volume', 211 | `${process.cwd()}/${projectDir}:/data`, 212 | '--workdir', 213 | '/data', 214 | ...platform, 215 | '--env', 216 | `SNAPCRAFT_IMAGE_INFO={"build_url":"https://github.com/user/repo/actions/runs/42"}`, 217 | '--env', 218 | 'SNAPCRAFT_BUILD_INFO=1', 219 | '--env', 220 | `USE_SNAPCRAFT_CHANNEL=${channel}`, 221 | '--systemd', 222 | 'always', 223 | `docker.io/diddledani/snapcraft:${base}`, 224 | 'snapcraft' 225 | ], 226 | { 227 | cwd: `${process.cwd()}/${projectDir}` 228 | } 229 | ) 230 | }) 231 | } 232 | 233 | test('SnapcraftBuilder.build can disable build info', async () => { 234 | expect.assertions(1) 235 | 236 | const ensureDockerExperimentalMock = jest 237 | .spyOn(tools, 'ensureDockerExperimental') 238 | .mockImplementation(async (): Promise => Promise.resolve()) 239 | const detectBaseMock = jest 240 | .spyOn(tools, 'detectBase') 241 | .mockImplementation( 242 | async (projectRoot: string): Promise => default_base 243 | ) 244 | const execMock = jest 245 | .spyOn(exec, 'exec') 246 | .mockImplementation( 247 | async (program: string, args?: string[]): Promise => 0 248 | ) 249 | 250 | const builder = new build.SnapcraftBuilder( 251 | '.', 252 | false, 253 | 'stable', 254 | '', 255 | '', 256 | [], 257 | false, 258 | '' 259 | ) 260 | await builder.build() 261 | 262 | expect(execMock).toHaveBeenLastCalledWith( 263 | 'docker', 264 | [ 265 | 'run', 266 | '--rm', 267 | '--tty', 268 | '--privileged', 269 | '--volume', 270 | `${process.cwd()}:/data`, 271 | '--workdir', 272 | '/data', 273 | '--env', 274 | `SNAPCRAFT_IMAGE_INFO={"build_url":"https://github.com/user/repo/actions/runs/42"}`, 275 | '--env', 276 | 'USE_SNAPCRAFT_CHANNEL=stable', 277 | `diddledani/snapcraft:${default_base}`, 278 | 'snapcraft' 279 | ], 280 | { 281 | cwd: expect.any(String) 282 | } 283 | ) 284 | }) 285 | 286 | test('SnapcraftBuilder.build can pass additional arguments', async () => { 287 | expect.assertions(1) 288 | 289 | const ensureDockerExperimentalMock = jest 290 | .spyOn(tools, 'ensureDockerExperimental') 291 | .mockImplementation(async (): Promise => Promise.resolve()) 292 | const detectBaseMock = jest 293 | .spyOn(tools, 'detectBase') 294 | .mockImplementation( 295 | async (projectRoot: string): Promise => default_base 296 | ) 297 | const execMock = jest 298 | .spyOn(exec, 'exec') 299 | .mockImplementation( 300 | async (program: string, args?: string[]): Promise => 0 301 | ) 302 | 303 | const builder = new build.SnapcraftBuilder( 304 | '.', 305 | false, 306 | 'stable', 307 | '--foo --bar', 308 | '', 309 | [], 310 | false, 311 | '' 312 | ) 313 | await builder.build() 314 | 315 | expect(execMock).toHaveBeenLastCalledWith( 316 | 'docker', 317 | [ 318 | 'run', 319 | '--rm', 320 | '--tty', 321 | '--privileged', 322 | '--volume', 323 | `${process.cwd()}:/data`, 324 | '--workdir', 325 | '/data', 326 | '--env', 327 | `SNAPCRAFT_IMAGE_INFO={"build_url":"https://github.com/user/repo/actions/runs/42"}`, 328 | '--env', 329 | 'USE_SNAPCRAFT_CHANNEL=stable', 330 | `diddledani/snapcraft:${default_base}`, 331 | 'snapcraft', 332 | '--foo', 333 | '--bar' 334 | ], 335 | expect.anything() 336 | ) 337 | }) 338 | 339 | test('SnapcraftBuilder.build can pass extra environment variables', async () => { 340 | expect.assertions(1) 341 | 342 | const ensureDockerExperimentalMock = jest 343 | .spyOn(tools, 'ensureDockerExperimental') 344 | .mockImplementation(async (): Promise => Promise.resolve()) 345 | const detectBaseMock = jest 346 | .spyOn(tools, 'detectBase') 347 | .mockImplementation( 348 | async (projectRoot: string): Promise => default_base 349 | ) 350 | const execMock = jest 351 | .spyOn(exec, 'exec') 352 | .mockImplementation( 353 | async (program: string, args?: string[]): Promise => 0 354 | ) 355 | 356 | const builder = new build.SnapcraftBuilder( 357 | '.', 358 | false, 359 | 'stable', 360 | '--foo --bar', 361 | '', 362 | ['FOO=bar', 'BAZ=qux'], 363 | false, 364 | '' 365 | ) 366 | await builder.build() 367 | 368 | expect(execMock).toHaveBeenLastCalledWith( 369 | 'docker', 370 | [ 371 | 'run', 372 | '--rm', 373 | '--tty', 374 | '--privileged', 375 | '--volume', 376 | `${process.cwd()}:/data`, 377 | '--workdir', 378 | '/data', 379 | '--env', 380 | 'FOO=bar', 381 | '--env', 382 | 'BAZ=qux', 383 | '--env', 384 | `SNAPCRAFT_IMAGE_INFO={"build_url":"https://github.com/user/repo/actions/runs/42"}`, 385 | '--env', 386 | 'USE_SNAPCRAFT_CHANNEL=stable', 387 | `diddledani/snapcraft:${default_base}`, 388 | 'snapcraft', 389 | '--foo', 390 | '--bar' 391 | ], 392 | expect.anything() 393 | ) 394 | }) 395 | 396 | test('SnapcraftBuilder.build adds store credentials', async () => { 397 | expect.assertions(1) 398 | 399 | const ensureDockerExperimentalMock = jest 400 | .spyOn(tools, 'ensureDockerExperimental') 401 | .mockImplementation(async (): Promise => Promise.resolve()) 402 | const detectBaseMock = jest 403 | .spyOn(tools, 'detectBase') 404 | .mockImplementation( 405 | async (projectRoot: string): Promise => default_base 406 | ) 407 | const execMock = jest 408 | .spyOn(exec, 'exec') 409 | .mockImplementation( 410 | async (program: string, args?: string[]): Promise => 0 411 | ) 412 | 413 | const builder = new build.SnapcraftBuilder( 414 | '.', 415 | false, 416 | 'stable', 417 | '--foo --bar', 418 | '', 419 | [], 420 | false, 421 | 'TEST_STORE_CREDENTIALS' 422 | ) 423 | await builder.build() 424 | 425 | expect(execMock).toHaveBeenLastCalledWith( 426 | 'docker', 427 | [ 428 | 'run', 429 | '--rm', 430 | '--tty', 431 | '--privileged', 432 | '--volume', 433 | `${process.cwd()}:/data`, 434 | '--workdir', 435 | '/data', 436 | '--env', 437 | `SNAPCRAFT_IMAGE_INFO={"build_url":"https://github.com/user/repo/actions/runs/42"}`, 438 | '--env', 439 | 'USE_SNAPCRAFT_CHANNEL=stable', 440 | '--env', 441 | 'SNAPCRAFT_STORE_CREDENTIALS=TEST_STORE_CREDENTIALS', 442 | `diddledani/snapcraft:${default_base}`, 443 | 'snapcraft', 444 | '--foo', 445 | '--bar' 446 | ], 447 | expect.anything() 448 | ) 449 | }) 450 | 451 | test('SnapcraftBuilder.outputSnap fails if there are no snaps', async () => { 452 | expect.assertions(2) 453 | 454 | const projectDir = 'project-root' 455 | const builder = new build.SnapcraftBuilder( 456 | projectDir, 457 | true, 458 | 'stable', 459 | '', 460 | '', 461 | [], 462 | false, 463 | '' 464 | ) 465 | 466 | const readdir = jest 467 | .spyOn(builder, '_readdir') 468 | .mockImplementation( 469 | async (path: string): Promise => ['not-a-snap'] 470 | ) 471 | 472 | await expect(builder.outputSnap()).rejects.toThrow( 473 | 'No snap files produced by build' 474 | ) 475 | expect(readdir).toHaveBeenCalled() 476 | }) 477 | 478 | test('SnapcraftBuilder.outputSnap returns the first snap', async () => { 479 | expect.assertions(2) 480 | 481 | const projectDir = 'project-root' 482 | const builder = new build.SnapcraftBuilder( 483 | projectDir, 484 | true, 485 | 'stable', 486 | '', 487 | '', 488 | [], 489 | false, 490 | '' 491 | ) 492 | 493 | const readdir = jest 494 | .spyOn(builder, '_readdir') 495 | .mockImplementation( 496 | async (path: string): Promise => ['one.snap', 'two.snap'] 497 | ) 498 | const execMock = jest 499 | .spyOn(exec, 'exec') 500 | .mockImplementation( 501 | async (program: string, args?: string[]): Promise => { 502 | return 0 503 | } 504 | ) 505 | const warning = jest 506 | .spyOn(core, 'warning') 507 | .mockImplementation((_message: string | Error): void => {}) 508 | 509 | await expect(builder.outputSnap()).resolves.toEqual('./project-root/one.snap') 510 | expect(readdir).toHaveBeenCalled() 511 | }) 512 | --------------------------------------------------------------------------------