├── .eslintignore ├── .prettierignore ├── .gitattributes ├── sample-project ├── bin │ └── hello.sh └── snap │ └── snapcraft.yaml ├── add-secret.png ├── .prettierrc.json ├── jest.config.js ├── action.yml ├── src ├── main.ts ├── tools.ts └── publish.ts ├── .github └── workflows │ └── test.yml ├── LICENSE ├── tsconfig.json ├── CONTRIBUTING.md ├── package.json ├── .gitignore ├── .eslintrc.json ├── README.md └── __tests__ ├── tools.test.ts └── publish.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 | -------------------------------------------------------------------------------- /sample-project/bin/hello.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo 'Hello world' 3 | -------------------------------------------------------------------------------- /add-secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canonical/action-publish/master/add-secret.png -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /sample-project/snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: test-jamesh-publish-action 2 | base: core22 3 | version: '0.1' 4 | summary: A snap used to test the Github publish action 5 | description: | 6 | This is a trivial snap used to test that the Github publish action 7 | can successfully publish snaps to the store. 8 | 9 | grade: stable 10 | confinement: strict 11 | 12 | apps: 13 | test-jamesh-publish-action: 14 | command: hello.sh 15 | 16 | parts: 17 | build: 18 | # See 'snapcraft plugins' 19 | plugin: dump 20 | source: bin 21 | stage: 22 | - hello.sh 23 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Snapcraft Publish' 2 | description: 'Publish a Snapcraft project to the Snap Store' 3 | author: 'James Henstridge' 4 | branding: 5 | icon: 'upload' 6 | color: 'orange' 7 | inputs: 8 | store_login: 9 | description: 'The login data for the Snap Store produced with "snapcraft export-login"' 10 | required: false 11 | snap: 12 | description: 'The snap file to upload to the store' 13 | required: true 14 | release: 15 | description: 'A comma separated list of channels to release the snap to' 16 | required: false 17 | runs: 18 | using: 'node20' 19 | main: 'dist/index.js' 20 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as core from '@actions/core' 4 | import {SnapcraftPublisher} from './publish' 5 | 6 | async function run(): Promise { 7 | try { 8 | const loginData: string = core.getInput('store_login') 9 | const snapFile: string = core.getInput('snap') 10 | const release: string = core.getInput('release') 11 | core.info(`Publishing snap "${snapFile}"...`) 12 | 13 | const publisher = new SnapcraftPublisher({loginData, snapFile, release}) 14 | await publisher.validate() 15 | await publisher.publish() 16 | } catch (error) { 17 | core.setFailed((error as Error)?.message) 18 | } 19 | } 20 | 21 | run() 22 | -------------------------------------------------------------------------------- /.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 | - run: | 15 | npm install 16 | npm run all 17 | integration: # make sure the action works on a clean machine without building 18 | # Only run integration test if we have access to secrets 19 | if: github.event_name != 'pull_request' 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: snapcore/action-build@v1 24 | id: build 25 | with: 26 | path: './sample-project' 27 | - uses: ./ 28 | with: 29 | snap: ${{ steps.build.outputs.snap }} 30 | release: edge 31 | env: 32 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.STORE_LOGIN }} 33 | -------------------------------------------------------------------------------- /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-publish-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/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 | 7 | async function haveExecutable(path: string): Promise { 8 | try { 9 | await fs.promises.access(path, fs.constants.X_OK) 10 | } catch (err) { 11 | return false 12 | } 13 | return true 14 | } 15 | 16 | export async function ensureSnapd(): Promise { 17 | const haveSnapd = await haveExecutable('/usr/bin/snap') 18 | if (!haveSnapd) { 19 | core.info('Installing snapd...') 20 | await exec.exec('sudo', ['apt-get', 'update', '-q']) 21 | await exec.exec('sudo', ['apt-get', 'install', '-qy', 'snapd']) 22 | } 23 | // The Github worker environment has weird permissions on the root, 24 | // which trip up snap-confine. 25 | const root = await fs.promises.stat('/') 26 | if (root.uid !== 0 || root.gid !== 0) { 27 | await exec.exec('sudo', ['chown', 'root:root', '/']) 28 | } 29 | } 30 | 31 | export async function ensureSnapcraft(): Promise { 32 | const haveSnapcraft = await haveExecutable('/snap/bin/snapcraft') 33 | if (!haveSnapcraft) { 34 | core.info('Installing Snapcraft...') 35 | await exec.exec('sudo', ['snap', 'install', '--classic', 'snapcraft']) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snapcraft-publish-action", 3 | "version": "1.2.0", 4 | "private": true, 5 | "description": "A Github action that publishes 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-publish.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.6", 33 | "@types/node": "^20.8.9", 34 | "@typescript-eslint/eslint-plugin": "^6.9.0", 35 | "@typescript-eslint/parser": "^6.9.0", 36 | "@vercel/ncc": "^0.38.1", 37 | "eslint": "^8.52.0", 38 | "eslint-plugin-github": "^4.10.1", 39 | "eslint-plugin-jest": "^27.6.0", 40 | "eslint-plugin-prettier": "^5.0.1", 41 | "jest": "^29.7.0", 42 | "jest-circus": "^29.7.0", 43 | "prettier": "^3.0.3", 44 | "ts-jest": "^29.1.1", 45 | "typescript": "^5.2.2" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.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/**/* -------------------------------------------------------------------------------- /src/publish.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 os from 'os' 6 | import * as path from 'path' 7 | import * as tools from './tools' 8 | 9 | interface SnapcraftPublisherOptions { 10 | loginData: string 11 | snapFile: string 12 | release: string 13 | } 14 | 15 | export class SnapcraftPublisher { 16 | loginData: string 17 | snapFile: string 18 | release: string 19 | 20 | constructor(options: SnapcraftPublisherOptions) { 21 | this.loginData = options.loginData 22 | this.snapFile = options.snapFile 23 | this.release = options.release 24 | } 25 | 26 | async validate(): Promise { 27 | if (process.env.SNAPCRAFT_STORE_CREDENTIALS) { 28 | return 29 | } 30 | 31 | if (!this.loginData) { 32 | throw new Error('login_data is empty') 33 | } 34 | try { 35 | await fs.promises.access(this.snapFile, fs.constants.R_OK) 36 | } catch (error) { 37 | throw new Error(`cannot read snap file "${this.snapFile}"`) 38 | } 39 | } 40 | 41 | async login(): Promise { 42 | const tmpdir = await fs.promises.mkdtemp( 43 | path.join(os.tmpdir(), 'login-data-') 44 | ) 45 | try { 46 | const loginfile = path.join(tmpdir, 'login.txt') 47 | await fs.promises.writeFile(loginfile, this.loginData) 48 | await exec.exec('snapcraft', ['login', '--with', loginfile]) 49 | } finally { 50 | await fs.promises.rmdir(tmpdir, {recursive: true}) 51 | } 52 | } 53 | 54 | async upload(): Promise { 55 | const args = ['upload', this.snapFile] 56 | if (this.release) { 57 | args.push('--release') 58 | args.push(this.release) 59 | } 60 | await exec.exec('snapcraft', args) 61 | } 62 | 63 | async logout(): Promise { 64 | await exec.exec('snapcraft', ['logout']) 65 | } 66 | 67 | async publish(): Promise { 68 | await tools.ensureSnapd() 69 | await tools.ensureSnapcraft() 70 | if (!process.env.SNAPCRAFT_STORE_CREDENTIALS) { 71 | await this.login() 72 | } 73 | try { 74 | await this.upload() 75 | } finally { 76 | if (!process.env.SNAPCRAFT_STORE_CREDENTIALS) { 77 | await this.logout() 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | snapcraft-publish-action status 3 |

4 | 5 | # Snapcraft Publish Action 6 | 7 | This is a Github Action that can be used to publish [snap 8 | packages](https://snapcraft.io) to the Snap Store. In most cases, it 9 | will be used with the `snapcraft-build-action` action to build the 10 | package. The following workflow should be sufficient for Snapcraft 7 or later: 11 | 12 | ```yaml 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: snapcore/action-build@v1 19 | id: build 20 | - uses: snapcore/action-publish@v1 21 | env: 22 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.STORE_LOGIN }} 23 | with: 24 | snap: ${{ steps.build.outputs.snap }} 25 | release: edge 26 | ``` 27 | 28 | Alternatively, on Snapcraft 6 and older: 29 | ```yaml 30 | jobs: 31 | build: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: snapcore/action-build@v1 36 | id: build 37 | - uses: snapcore/action-publish@v1 38 | with: 39 | store_login: ${{ secrets.STORE_LOGIN }} 40 | snap: ${{ steps.build.outputs.snap }} 41 | release: edge 42 | ``` 43 | 44 | This will build the project, upload the result to the store, and 45 | release it to the `edge` channel. If the `release` input parameter is 46 | omitted, then the build will not be uploaded but not released. 47 | 48 | 49 | ## Store Login 50 | 51 | In order to upload to the store, the action requires login 52 | credentials. Rather than a user name and password, the action expects 53 | the data produced by the `snapcraft export-login` command. 54 | 55 | As well as preventing the exposure of the password, it also allows the 56 | credentials to be locked down to only the access the action requires: 57 | 58 | ```sh 59 | $ snapcraft export-login --snaps=PACKAGE_NAME \ 60 | --acls package_access,package_push,package_update,package_release \ 61 | exported.txt 62 | ``` 63 | 64 | This will produce a file `exported.txt` containing the login data. 65 | The credentials can be restricted further with the `--channels` and 66 | `--expires` arguments if desired. 67 | 68 | In order to make the credentials available to the workflow, they 69 | should be stored as a repository secret: 70 | 71 | 1. Select the "Settings" tab. 72 | 2. Select "Secrets and variables > Actions" from the menu on the left. 73 | 3. Click "New repository secret". 74 | 4. Set the name to `STORE_LOGIN` (or whatever is referenced in the workflow), and paste the contents of `exported.txt` as the value. 75 | 76 | ![Screenshot depicting secrets configuration](add-secret.png) 77 | 78 | Note that while this secret may be named arbitrarily, 79 | the secret must be passed to the action in the `env` as `SNAPCRAFT_STORE_CREDENTIALS`. 80 | See [snapcraft authentication options docs](https://snapcraft.io/docs/snapcraft-authentication#snapcraft_store_credentials-environment-variable) for more information. 81 | 82 | ```yaml 83 | - uses: snapcore/action-publish@v1 84 | env: 85 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.STORE_LOGIN }} 86 | ``` 87 | -------------------------------------------------------------------------------- /__tests__/tools.test.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as exec from '@actions/exec' 4 | import fs = require('fs') 5 | import * as os from 'os' 6 | import * as path from 'path' 7 | import * as tools from '../src/tools' 8 | 9 | afterEach(() => { 10 | jest.restoreAllMocks() 11 | }) 12 | 13 | test('ensureSnapd installs snapd if needed', async () => { 14 | expect.assertions(4) 15 | 16 | const accessMock = jest 17 | .spyOn(fs.promises, 'access') 18 | .mockImplementation( 19 | async ( 20 | filename: fs.PathLike, 21 | mode?: number | undefined 22 | ): Promise => { 23 | throw new Error('not found') 24 | } 25 | ) 26 | const statMock = jest 27 | .spyOn(fs.promises, 'stat') 28 | .mockImplementation(async (filename: fs.PathLike): Promise => { 29 | const s = new fs.Stats() 30 | s.uid = 0 31 | s.gid = 0 32 | return s 33 | }) 34 | const execMock = jest 35 | .spyOn(exec, 'exec') 36 | .mockImplementation( 37 | async (program: string, args?: string[]): Promise => { 38 | return 0 39 | } 40 | ) 41 | 42 | await tools.ensureSnapd() 43 | 44 | expect(accessMock).toHaveBeenCalled() 45 | expect(statMock).toHaveBeenCalled() 46 | expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ 47 | 'apt-get', 48 | 'update', 49 | '-q' 50 | ]) 51 | expect(execMock).toHaveBeenNthCalledWith(2, 'sudo', [ 52 | 'apt-get', 53 | 'install', 54 | '-qy', 55 | 'snapd' 56 | ]) 57 | }) 58 | 59 | test('ensureSnapd is a no-op if snapd is installed', async () => { 60 | expect.assertions(3) 61 | 62 | const accessMock = jest 63 | .spyOn(fs.promises, 'access') 64 | .mockImplementation( 65 | async ( 66 | filename: fs.PathLike, 67 | mode?: number | undefined 68 | ): Promise => {} 69 | ) 70 | const statMock = jest 71 | .spyOn(fs.promises, 'stat') 72 | .mockImplementation(async (filename: fs.PathLike): Promise => { 73 | const s = new fs.Stats() 74 | s.uid = 0 75 | s.gid = 0 76 | return s 77 | }) 78 | const execMock = jest 79 | .spyOn(exec, 'exec') 80 | .mockImplementation( 81 | async (program: string, args?: string[]): Promise => { 82 | return 0 83 | } 84 | ) 85 | 86 | await tools.ensureSnapd() 87 | 88 | expect(accessMock).toHaveBeenCalled() 89 | expect(statMock).toHaveBeenCalled() 90 | expect(execMock).not.toHaveBeenCalled() 91 | }) 92 | 93 | test('ensureSnapd fixes permissions on the root directory', async () => { 94 | expect.assertions(3) 95 | 96 | const accessMock = jest 97 | .spyOn(fs.promises, 'access') 98 | .mockImplementation( 99 | async ( 100 | filename: fs.PathLike, 101 | mode?: number | undefined 102 | ): Promise => {} 103 | ) 104 | const statMock = jest 105 | .spyOn(fs.promises, 'stat') 106 | .mockImplementation(async (filename: fs.PathLike): Promise => { 107 | const s = new fs.Stats() 108 | s.uid = 500 109 | s.gid = 0 110 | return s 111 | }) 112 | const execMock = jest 113 | .spyOn(exec, 'exec') 114 | .mockImplementation( 115 | async (program: string, args?: string[]): Promise => { 116 | return 0 117 | } 118 | ) 119 | 120 | await tools.ensureSnapd() 121 | 122 | expect(accessMock).toHaveBeenCalled() 123 | expect(statMock).toHaveBeenCalled() 124 | expect(execMock).toHaveBeenCalledWith('sudo', ['chown', 'root:root', '/']) 125 | }) 126 | 127 | test('ensureSnapcraft installs Snapcraft if needed', async () => { 128 | expect.assertions(2) 129 | 130 | const accessMock = jest 131 | .spyOn(fs.promises, 'access') 132 | .mockImplementation( 133 | async ( 134 | filename: fs.PathLike, 135 | mode?: number | undefined 136 | ): Promise => { 137 | throw new Error('not found') 138 | } 139 | ) 140 | const execMock = jest 141 | .spyOn(exec, 'exec') 142 | .mockImplementation( 143 | async (program: string, args?: string[]): Promise => { 144 | return 0 145 | } 146 | ) 147 | 148 | await tools.ensureSnapcraft() 149 | 150 | expect(accessMock).toHaveBeenCalled() 151 | expect(execMock).toHaveBeenNthCalledWith(1, 'sudo', [ 152 | 'snap', 153 | 'install', 154 | '--classic', 155 | 'snapcraft' 156 | ]) 157 | }) 158 | 159 | test('ensureSnapcraft is a no-op if Snapcraft is installed', async () => { 160 | expect.assertions(2) 161 | 162 | const accessMock = jest 163 | .spyOn(fs.promises, 'access') 164 | .mockImplementation( 165 | async ( 166 | filename: fs.PathLike, 167 | mode?: number | undefined 168 | ): Promise => { 169 | return 170 | } 171 | ) 172 | const execMock = jest 173 | .spyOn(exec, 'exec') 174 | .mockImplementation( 175 | async (program: string, args?: string[]): Promise => { 176 | return 0 177 | } 178 | ) 179 | 180 | await tools.ensureSnapcraft() 181 | 182 | expect(accessMock).toHaveBeenCalled() 183 | expect(execMock).not.toHaveBeenCalled() 184 | }) 185 | -------------------------------------------------------------------------------- /__tests__/publish.test.ts: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript; js-indent-level: 2 -*- 2 | 3 | import * as fs from 'fs' 4 | import * as path from 'path' 5 | import * as exec from '@actions/exec' 6 | import * as publish from '../src/publish' 7 | import * as tools from '../src/tools' 8 | 9 | test('SnapcraftPublisher.validate validates inputs', async () => { 10 | expect.assertions(2) 11 | 12 | const existingSnap = path.join(__dirname, '..', 'README.md') 13 | const missingSnap = path.join(__dirname, 'no-such-snap.snap') 14 | 15 | // No error on valid inputs 16 | let publisher = new publish.SnapcraftPublisher({ 17 | loginData: 'login-data', 18 | snapFile: existingSnap, 19 | release: '' 20 | }) 21 | await publisher.validate() 22 | 23 | // Missing login data 24 | publisher = new publish.SnapcraftPublisher({ 25 | loginData: '', 26 | snapFile: existingSnap, 27 | release: '' 28 | }) 29 | await expect(publisher.validate()).rejects.toThrow('login_data is empty') 30 | 31 | // Missing snap 32 | publisher = new publish.SnapcraftPublisher({ 33 | loginData: 'login-data', 34 | snapFile: missingSnap, 35 | release: '' 36 | }) 37 | await expect(publisher.validate()).rejects.toThrow( 38 | `cannot read snap file "${missingSnap}"` 39 | ) 40 | }) 41 | 42 | test('SnapcraftPublisher.login deletes the login data', async () => { 43 | expect.assertions(2) 44 | 45 | const execMock = jest 46 | .spyOn(exec, 'exec') 47 | .mockImplementation( 48 | async (program: string, args?: string[]): Promise => { 49 | return 0 50 | } 51 | ) 52 | 53 | const publisher = new publish.SnapcraftPublisher({ 54 | loginData: 'login-data', 55 | snapFile: '', 56 | release: '' 57 | }) 58 | await publisher.login() 59 | expect(execMock).toHaveBeenCalledWith('snapcraft', [ 60 | 'login', 61 | '--with', 62 | expect.any(String) 63 | ]) 64 | const loginFile = (execMock.mock.calls[0][1] as string[])[2] as string 65 | expect(fs.existsSync(loginFile)).toBe(false) 66 | }) 67 | 68 | test('SnapcraftPublisher.login deletes the login data on login failure', async () => { 69 | expect.assertions(3) 70 | 71 | const execMock = jest 72 | .spyOn(exec, 'exec') 73 | .mockImplementation( 74 | async (program: string, args?: string[]): Promise => { 75 | throw new Error('login failure') 76 | } 77 | ) 78 | 79 | const publisher = new publish.SnapcraftPublisher({ 80 | loginData: 'login-data', 81 | snapFile: '', 82 | release: '' 83 | }) 84 | await expect(publisher.login()).rejects.toThrow('login failure') 85 | expect(execMock).toHaveBeenCalledWith('snapcraft', [ 86 | 'login', 87 | '--with', 88 | expect.any(String) 89 | ]) 90 | const loginFile = (execMock.mock.calls[0][1] as string[])[2] as string 91 | expect(fs.existsSync(loginFile)).toBe(false) 92 | }) 93 | 94 | test('SnapcraftPublisher.publish publishes the snap', async () => { 95 | expect.assertions(5) 96 | 97 | const ensureSnapd = jest 98 | .spyOn(tools, 'ensureSnapd') 99 | .mockImplementation(async (): Promise => {}) 100 | const ensureSnapcraft = jest 101 | .spyOn(tools, 'ensureSnapcraft') 102 | .mockImplementation(async (): Promise => {}) 103 | const execMock = jest 104 | .spyOn(exec, 'exec') 105 | .mockImplementation( 106 | async (program: string, args?: string[]): Promise => { 107 | return 0 108 | } 109 | ) 110 | 111 | const publisher = new publish.SnapcraftPublisher({ 112 | loginData: 'login-data', 113 | snapFile: 'filename.snap', 114 | release: '' 115 | }) 116 | await publisher.publish() 117 | 118 | expect(ensureSnapd).toHaveBeenCalled() 119 | expect(ensureSnapcraft).toHaveBeenCalled() 120 | expect(execMock).toHaveBeenCalledWith('snapcraft', [ 121 | 'login', 122 | '--with', 123 | expect.any(String) 124 | ]) 125 | expect(execMock).toHaveBeenCalledWith('snapcraft', [ 126 | 'upload', 127 | 'filename.snap' 128 | ]) 129 | expect(execMock).toHaveBeenCalledWith('snapcraft', ['logout']) 130 | }) 131 | 132 | test('SnapcraftPublisher.publish can release the published snap', async () => { 133 | expect.assertions(5) 134 | 135 | const ensureSnapd = jest 136 | .spyOn(tools, 'ensureSnapd') 137 | .mockImplementation(async (): Promise => {}) 138 | const ensureSnapcraft = jest 139 | .spyOn(tools, 'ensureSnapcraft') 140 | .mockImplementation(async (): Promise => {}) 141 | const execMock = jest 142 | .spyOn(exec, 'exec') 143 | .mockImplementation( 144 | async (program: string, args?: string[]): Promise => { 145 | return 0 146 | } 147 | ) 148 | 149 | const publisher = new publish.SnapcraftPublisher({ 150 | loginData: 'login-data', 151 | snapFile: 'filename.snap', 152 | release: 'edge' 153 | }) 154 | await publisher.publish() 155 | 156 | expect(ensureSnapd).toHaveBeenCalled() 157 | expect(ensureSnapcraft).toHaveBeenCalled() 158 | expect(execMock).toHaveBeenCalledWith('snapcraft', [ 159 | 'login', 160 | '--with', 161 | expect.any(String) 162 | ]) 163 | expect(execMock).toHaveBeenCalledWith('snapcraft', [ 164 | 'upload', 165 | 'filename.snap', 166 | '--release', 167 | 'edge' 168 | ]) 169 | expect(execMock).toHaveBeenCalledWith('snapcraft', ['logout']) 170 | }) 171 | 172 | describe('process.env', () => { 173 | const env = process.env 174 | 175 | beforeEach(() => { 176 | jest.resetModules() 177 | process.env = {...env} 178 | }) 179 | 180 | afterEach(() => { 181 | process.env = env 182 | }) 183 | 184 | test('SnapcraftPublisher.publish can release the published snap with new login', async () => { 185 | expect.assertions(5) 186 | 187 | const ensureSnapd = jest 188 | .spyOn(tools, 'ensureSnapd') 189 | .mockImplementation(async (): Promise => {}) 190 | const ensureSnapcraft = jest 191 | .spyOn(tools, 'ensureSnapcraft') 192 | .mockImplementation(async (): Promise => {}) 193 | const execMock = jest 194 | .spyOn(exec, 'exec') 195 | .mockImplementation( 196 | async (program: string, args?: string[]): Promise => { 197 | return 0 198 | } 199 | ) 200 | 201 | process.env.SNAPCRAFT_STORE_CREDENTIALS = 'credentials' 202 | const publisher = new publish.SnapcraftPublisher({ 203 | loginData: '', 204 | snapFile: 'filename.snap', 205 | release: 'edge' 206 | }) 207 | await publisher.publish() 208 | 209 | expect(ensureSnapd).toHaveBeenCalled() 210 | expect(ensureSnapcraft).toHaveBeenCalled() 211 | expect(execMock).not.toHaveBeenCalledWith('snapcraft', [ 212 | 'login', 213 | '--with', 214 | expect.any(String) 215 | ]) 216 | expect(execMock).toHaveBeenCalledWith('snapcraft', [ 217 | 'upload', 218 | 'filename.snap', 219 | '--release', 220 | 'edge' 221 | ]) 222 | expect(execMock).not.toHaveBeenCalledWith('snapcraft', ['logout']) 223 | }) 224 | 225 | test('SnapcraftPublisher.validate validates inputs with new login', async () => { 226 | expect.assertions(0) 227 | 228 | const existingSnap = path.join(__dirname, '..', 'README.md') 229 | process.env.SNAPCRAFT_STORE_CREDENTIALS = 'credentials' 230 | 231 | // Missing login data but env set 232 | let publisher = new publish.SnapcraftPublisher({ 233 | loginData: '', 234 | snapFile: existingSnap, 235 | release: '' 236 | }) 237 | await publisher.validate() 238 | }) 239 | }) 240 | --------------------------------------------------------------------------------