├── .editorconfig ├── .github ├── release-please │ ├── config.json │ └── manifest.json └── workflows │ ├── codeql-analysis.yml │ ├── compliance.yml │ ├── dependency-review.yml │ ├── lint.yml │ ├── nodejs.yml │ ├── release-please.yml │ └── types.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-push ├── .knip.jsonc ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── declaration.tsconfig.json ├── eslint.config.js ├── index.js ├── package.json ├── renovate.json ├── test └── github.spec.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/release-please/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/v16.12.0/schemas/config.json", 3 | "release-type": "node", 4 | "include-component-in-tag": false, 5 | "changelog-sections": [ 6 | { "type": "feat", "section": "🌟 Features", "hidden": false }, 7 | { "type": "fix", "section": "🩹 Fixes", "hidden": false }, 8 | { "type": "docs", "section": "📚 Documentation", "hidden": false }, 9 | 10 | { "type": "chore", "section": "🧹 Chores", "hidden": false }, 11 | { "type": "perf", "section": "🧹 Chores", "hidden": false }, 12 | { "type": "refactor", "section": "🧹 Chores", "hidden": false }, 13 | { "type": "test", "section": "🧹 Chores", "hidden": false }, 14 | 15 | { "type": "build", "section": "🤖 Automation", "hidden": false }, 16 | { "type": "ci", "section": "🤖 Automation", "hidden": true } 17 | ], 18 | "packages": { 19 | ".": {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/release-please/manifest.json: -------------------------------------------------------------------------------- 1 | {".":"6.0.0"} 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | - cron: '24 4 * * 2' 10 | 11 | permissions: 12 | actions: read 13 | contents: read 14 | security-events: write 15 | 16 | jobs: 17 | analyze: 18 | uses: voxpelli/ghatemplates/.github/workflows/codeql-analysis.yml@main 19 | -------------------------------------------------------------------------------- /.github/workflows/compliance.yml: -------------------------------------------------------------------------------- 1 | name: Compliance 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, edited, reopened] 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | compliance: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: mtfoley/pr-compliance-action@11b664f0fcf2c4ce954f05ccfcaab6e52b529f86 15 | with: 16 | body-auto-close: false 17 | body-regex: '.*' 18 | ignore-authors: | 19 | renovate 20 | renovate[bot] 21 | ignore-team-members: false 22 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | dependency-review: 10 | uses: voxpelli/ghatemplates/.github/workflows/dependency-review.yml@main 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | lint: 18 | uses: voxpelli/ghatemplates/.github/workflows/lint.yml@main 19 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | test: 18 | uses: voxpelli/ghatemplates/.github/workflows/test.yml@main 19 | with: 20 | node-versions: '18,20,21' 21 | os: 'ubuntu-latest,windows-latest' 22 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | id-token: write 11 | packages: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release-please: 16 | uses: voxpelli/ghatemplates/.github/workflows/release-please-4.yml@main 17 | secrets: inherit 18 | -------------------------------------------------------------------------------- /.github/workflows/types.yml: -------------------------------------------------------------------------------- 1 | name: Type Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | schedule: 13 | - cron: '14 5 * * 1,3,5' 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | type-check: 20 | uses: voxpelli/ghatemplates/.github/workflows/type-check.yml@main 21 | with: 22 | ts-versions: ${{ github.event.schedule && 'next' || '4.9,5.0,next' }} 23 | ts-libs: 'es2021;esnext' 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Basic ones 2 | /coverage 3 | /docs 4 | /node_modules 5 | /.env 6 | /.nyc_output 7 | 8 | # We're a library, so please, no lock files 9 | /package-lock.json 10 | /yarn.lock 11 | 12 | # Generated types 13 | *.d.ts 14 | *.d.ts.map 15 | 16 | # Library specific ones 17 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | npx --no validate-conventional-commit < .git/COMMIT_EDITMSG 4 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npm test 4 | -------------------------------------------------------------------------------- /.knip.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@2/schema.json", 3 | "ignoreDependencies": ["@types/mocha"] 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [6.0.0](https://github.com/voxpelli/node-github-publish/compare/v5.0.0...v6.0.0) (2024-06-25) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | * update undici and require node >=18.17.0 9 | 10 | ### 🩹 Fixes 11 | 12 | * update pony-cause ([328e80a](https://github.com/voxpelli/node-github-publish/commit/328e80afeeee1d1e9f6b38e540ad3bd51c0c6832)) 13 | * update undici and require node >=18.17.0 ([4e21206](https://github.com/voxpelli/node-github-publish/commit/4e212060a2c12f1f34d77d453b171f9dc311c77c)) 14 | 15 | 16 | ### 🧹 Chores 17 | 18 | * **deps:** update dev dependencies ([3975965](https://github.com/voxpelli/node-github-publish/commit/3975965ad7af3ba565626717f01c47e25be91fa9)) 19 | * **deps:** update to neostandard based linting ([7021bf8](https://github.com/voxpelli/node-github-publish/commit/7021bf8f512401bf6a2ac6a8f032be1feb4bead8)) 20 | * **deps:** update type dependencies ([50f9e31](https://github.com/voxpelli/node-github-publish/commit/50f9e311ba488f5fa9f560d6260291898cc3907e)) 21 | 22 | ## 5.0.0 (2023-10-29) 23 | 24 | * **Breaking change:** Now requires Node v18 (as v16 and older [has reached end-of-life](https://github.com/nodejs/Release)) 25 | * **Internal:** Updated dev dependencies to latest versions 26 | 27 | ## 4.0.0 (2023-05-10) 28 | 29 | * **Breaking change:** Now requires Node v16 (as v14 and older [has reached end-of-life](https://github.com/nodejs/Release)) 30 | * **Breaking change:** Is now an ESM module ([see this](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)) 31 | * **Breaking change:** Made private methods actually private 32 | * **Improvements:** Moved to `undici` from `node-fetch`and replaced `nock` with the `MockAgent` in `undici` 33 | * **Improvements:** Added 100% type coverage + generates type declarations for publishing 34 | 35 | ## 4.0.0-3 (2022-07-13) 36 | 37 | * **Breaking change:** Now requires Node v14.18 38 | * **Improvements:** Update `pony-cause` to version with ESM-module 39 | * **Internal:** Updated dev dependencies to latest versions 40 | 41 | ## 4.0.0-2 (2022-04-24) 42 | 43 | * **Fix:** Restore Node 14 compatibility: Replaced the `fetch` implementation from `undici` with its core `request()`, since it only supports `fetch` on Node 16 and later 44 | 45 | ## 4.0.0-1 (2022-04-24) 46 | 47 | * **Breaking change:** Now requires Node v14 48 | * **Breaking change:** Is now an ESM module ([see this](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)) 49 | * **Breaking change:** Made private methods actually private 50 | * **Improvements:** Moved to `undici` from `node-fetch`and replaced `nock` with the `MockAgent` in `undici` 51 | * **Improvements:** Added 100% type coverage + generates type declarations for publishing 52 | 53 | ## 4.0.0-0 (2020-04-28) 54 | 55 | * **Breaking change:** Now requires Node v12 (Twice the Node version!) 56 | 57 | ## 3.0.0 (2018-01-03) 58 | 59 | * **Breaking change:** Old `retrieve()` is now `retrieveRaw()` and new `retrieve()` returns mimicks `publish()` in that it returns the decoded content 60 | * **Improvements:** Updated dev dependencies 61 | * **Improvements:** Updated Travis test targets 62 | 63 | ## 2.0.0 (2016-10-23) 64 | 65 | * **Breaking change:** Now requires Node v6 66 | * **Improvements:** Updated dev dependencies and moved to a Grunt-less, [semistandard](https://github.com/Flet/semistandard)-based setup through [ESLint](http://eslint.org/) 67 | * **Improvements:** Updated Travis definition and test targets 68 | * **Minor:** Added `yarn.lock` to `.gitignore` as this is a library and [libraries don't use lock files](https://github.com/yarnpkg/yarn/issues/838#issuecomment-253362537) 69 | 70 | ## 1.1.0 (2015-07-21) 71 | 72 | 73 | #### Features 74 | 75 | * **main:** allow custom commit messages ([796a397c](https://github.com/voxpelli/node-github-publish/commit/796a397ce17f7b34595a53c9237f7f1d001b6b13)) 76 | 77 | 78 | ### 1.0.1 (2015-07-20) 79 | 80 | 81 | #### Bug Fixes 82 | 83 | * **main:** explicitly support Buffers as input ([c07bd364](https://github.com/voxpelli/node-github-publish/commit/c07bd3646d2fcb29ec45b70d20043073f5204236)) 84 | 85 | 86 | ## 1.0.0 (2015-07-17) 87 | 88 | 89 | #### Bug Fixes 90 | 91 | * **main:** limit the result of a retrieve ([afd5d8c7](https://github.com/voxpelli/node-github-publish/commit/afd5d8c7c0daa40d7d2274d4e4296dbfe2cac8c2)) 92 | 93 | 94 | #### Features 95 | 96 | * **main:** return the created sha on success ([c215ac6d](https://github.com/voxpelli/node-github-publish/commit/c215ac6d59cfaaf9c25100eb3d02b170d6f708da)) 97 | 98 | 99 | ### 0.1.4 (2015-07-07) 100 | 101 | 102 | ### 0.1.3 (2015-07-07) 103 | 104 | 105 | #### Features 106 | 107 | * **main:** retrieve + force publish capabilities ([65186835](https://github.com/bloglovin/node-github-publish/commit/65186835109ea781a3229d8a24f712fdbc2c88ba)) 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Pelle Wessman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Publish 2 | 3 | [![npm version](https://img.shields.io/npm/v/github-publish.svg?style=flat)](https://www.npmjs.com/package/github-publish) 4 | [![npm downloads](https://img.shields.io/npm/dm/github-publish.svg?style=flat)](https://www.npmjs.com/package/github-publish) 5 | [![Module type: ESM](https://img.shields.io/badge/module%20type-esm-brightgreen)](https://github.com/voxpelli/badges-cjs-esm) 6 | [![Types in JS](https://img.shields.io/badge/types_in_js-yes-brightgreen)](https://github.com/voxpelli/types-in-js) 7 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-7fffff?style=flat&labelColor=ff80ff)](https://github.com/neostandard/neostandard) 8 | [![Follow @voxpelli@mastodon.social](https://img.shields.io/mastodon/follow/109247025527949675?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@voxpelli) 9 | 10 | Publishes a file to a repository through the GitHub Contents API 11 | 12 | ## Installation 13 | 14 | ### NPM 15 | ```bash 16 | npm install github-publish 17 | ``` 18 | 19 | ## Current status 20 | 21 | **Stable, but not feature complete** 22 | 23 | Currently missing support for deletes. 24 | 25 | ## Usage 26 | 27 | ```javascript 28 | import { GitHubPublisher } from 'github-publish'; 29 | 30 | const publisher = new GitHubPublisher('token123', 'voxpelli', 'voxpelli.github.com'); 31 | 32 | const result = await publisher.publish('_post/2015-07-17-example-post.md', 'file content'); 33 | 34 | // If "result" is truthy then the post was successfully published 35 | ``` 36 | 37 | ## Classes 38 | 39 | * **GitHubPublisher(token, username, repo, [branch])** – creates a publisher object with an [access token](https://developer.github.com/v3/#authentication) for the GitHub API, the `username` of the owner of the repository to publish to and the name of the repository itself as `repo`. 40 | 41 | ## `GitHubPublisher` methods 42 | 43 | * **retrieve(filename)** – returns a `Promise` that resolves with either an object containing the `content` and `sha` of the existing file or with `false` if no such file exists in the repository 44 | * **publish(filename, content, [options])** – publishes the specified `content` as the `filename` to the `repo` of the publisher object. `content` should be either a `string` or a `Buffer`. Returns a `Promise` which resolves to the `sha` of the created object on success and to `false` on failure (failure is likely caused by a collision with a pre-existing file, as long as one haven't specified that it should be overridden). 45 | 46 | ## `publish()` options 47 | 48 | * **force** – whether to replace any pre-existing file no matter what 49 | * **message** – a custom commit message. Default is `new content` 50 | * **sha** – the sha of an existing file that one wants to replace 51 | -------------------------------------------------------------------------------- /declaration.tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "extends": "./tsconfig", 4 | "exclude": [ 5 | "test/**/*.js" 6 | ], 7 | "compilerOptions": { 8 | "declaration": true, 9 | "declarationMap": true, 10 | "noEmit": false, 11 | "emitDeclarationOnly": true, 12 | "removeComments": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | export { default } from '@voxpelli/eslint-config'; 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { ErrorWithCause } from 'pony-cause'; 2 | import { request } from 'undici'; 3 | 4 | // FIXME: Why is there a "#private;" field exported in the types? 5 | export class GitHubPublisher { 6 | /** @type {string} */ 7 | #token; 8 | /** @type {string} */ 9 | #user; 10 | /** @type {string} */ 11 | #repo; 12 | /** @type {string|undefined} */ 13 | #branch; 14 | 15 | /** 16 | * @param {string} token 17 | * @param {string} user 18 | * @param {string} repo 19 | * @param {string|undefined} [branch] 20 | */ 21 | constructor (token, user, repo, branch) { 22 | this.#token = token; 23 | this.#user = user; 24 | this.#repo = repo; 25 | this.#branch = branch; 26 | } 27 | 28 | #getBaseHeaders () { 29 | return { 30 | authorization: 'Bearer ' + this.#token, 31 | accept: 'application/vnd.github.v3+json', 32 | 'user-agent': this.#user, 33 | }; 34 | } 35 | 36 | /** 37 | * @param {string} path 38 | * @returns {Promise<{ ok: boolean, json: () => Promise, status: number }>} 39 | */ 40 | async #getRequest (path) { 41 | let url = 'https://api.github.com' + path; 42 | 43 | if (this.#branch) { 44 | url += '?ref=' + encodeURIComponent(this.#branch); 45 | } 46 | 47 | const { body, statusCode } = await request(url, { headers: this.#getBaseHeaders() }); 48 | 49 | return { 50 | json: /** @returns {Promise} */ () => body.json(), 51 | ok: statusCode < 300, 52 | status: statusCode, 53 | }; 54 | } 55 | 56 | /** 57 | * @param {string} path 58 | * @param {Record} data 59 | * @returns {Promise<{ ok: boolean, json: () => Promise, status: number }>} 60 | */ 61 | async #putRequest (path, data) { 62 | const options = { 63 | body: JSON.stringify(data), 64 | headers: { 65 | ...this.#getBaseHeaders(), 66 | 'content-type': 'application/json', 67 | }, 68 | }; 69 | 70 | const url = 'https://api.github.com' + path; 71 | 72 | const { body, statusCode } = await request(url, options); 73 | 74 | return { 75 | json: /** @returns {Promise} */ () => body.json(), 76 | ok: statusCode < 300, 77 | status: statusCode, 78 | }; 79 | } 80 | 81 | /** 82 | * @param {string|Buffer} text 83 | * @returns {string} 84 | */ 85 | #base64 (text) { 86 | const data = text instanceof Buffer ? text : Buffer.from(text); 87 | return data.toString('base64'); 88 | } 89 | 90 | /** 91 | * @param {string} text 92 | * @returns {string} 93 | */ 94 | #base64decode (text) { 95 | return Buffer.from(text, 'base64').toString('utf8'); 96 | } 97 | 98 | /** 99 | * @param {string} file 100 | * @returns {string} 101 | */ 102 | #getPath (file) { 103 | return '/repos/' + this.#user + '/' + this.#repo + '/contents/' + file; 104 | } 105 | 106 | /** 107 | * @param {string} file 108 | * @returns {Promise} 109 | */ 110 | async #retrieveRaw (file) { 111 | const res = await this.#getRequest(this.#getPath(file)) 112 | .catch(/** @param {unknown} err */ err => { 113 | throw new ErrorWithCause('Failed to retrieve file from GitHub', { cause: err }); 114 | }); 115 | if (!res.ok) return false; 116 | 117 | const body = await res.json(); 118 | 119 | // TODO: Throw an error instead? 120 | if (!body || typeof body !== 'object') return false; 121 | 122 | const { content, sha } = /** @type {{ content?: string, sha?: string }} */ (body); 123 | 124 | // TODO: Throw an error instead? 125 | if (content === undefined || sha === undefined) return false; 126 | 127 | return { content, sha }; 128 | } 129 | 130 | /** 131 | * @param {string} file 132 | * @returns {Promise} 133 | */ 134 | async retrieve (file) { 135 | const result = await this.#retrieveRaw(file); 136 | 137 | if (!result) return false; 138 | 139 | return { 140 | content: result.content ? this.#base64decode(result.content) : undefined, 141 | sha: result.sha, 142 | }; 143 | } 144 | 145 | /** 146 | * @param {string} file 147 | * @param {string|Buffer} content 148 | * @param {object} options 149 | * @param {boolean} [options.force] 150 | * @param {string} [options.message] 151 | * @param {string} [options.sha] 152 | * @returns {Promise} 153 | */ 154 | async publish (file, content, options = {}) { 155 | const { force, message, sha } = options; 156 | 157 | const data = { 158 | message: message || 'new content', 159 | content: this.#base64(content), 160 | ...(sha ? { sha } : {}), 161 | ...(this.#branch ? { branch: this.#branch } : {}), 162 | }; 163 | 164 | const res = await this.#putRequest(this.#getPath(file), data); 165 | 166 | if (!res.ok && res.status === 422 && force === true) { 167 | const currentData = await this.retrieve(file); 168 | 169 | if (!currentData || !currentData.sha) { 170 | return false; 171 | } 172 | 173 | return this.publish(file, content, { 174 | ...options, 175 | force: undefined, 176 | sha: currentData.sha, 177 | }); 178 | } 179 | 180 | const body = await res.json(); 181 | 182 | if (res.ok === false) { 183 | console.log('GitHub Error', body); 184 | return false; 185 | } 186 | 187 | if (!body || typeof body !== 'object') { 188 | console.log('Invalid body returned:', body); 189 | return false; 190 | } 191 | 192 | const { content: { sha: createdSha } = {} } = /** @type {{ content?: { sha?: string }}} */ (body); 193 | 194 | if (!createdSha) { 195 | console.log('Invalid sha returned. Full body:', body); 196 | return false; 197 | } 198 | 199 | return createdSha; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-publish", 3 | "version": "6.0.0", 4 | "license": "MIT", 5 | "description": "Publishes a file to a repository through the GitHub Contents API", 6 | "author": "Pelle Wessman (http://kodfabrik.se/)", 7 | "homepage": "https://github.com/voxpelli/node-github-publish", 8 | "repository": { 9 | "type": "git", 10 | "url": "git://github.com/voxpelli/node-github-publish.git" 11 | }, 12 | "type": "module", 13 | "exports": "./index.js", 14 | "engines": { 15 | "node": ">=18.17.0" 16 | }, 17 | "files": [ 18 | "index.js", 19 | "index.d.ts", 20 | "index.d.ts.map" 21 | ], 22 | "scripts": { 23 | "build:0": "run-s clean", 24 | "build:1-declaration": "tsc -p declaration.tsconfig.json", 25 | "build": "run-s build:*", 26 | "check:installed-check": "installed-check -i @voxpelli/eslint-config -i eslint", 27 | "check:knip": "knip", 28 | "check:lint": "eslint --report-unused-disable-directives --color .", 29 | "check:tsc": "tsc", 30 | "check:type-coverage": "type-coverage --detail --strict --at-least 99 --ignore-files 'test/*'", 31 | "check": "run-s clean && run-p -c --aggregate-output check:*", 32 | "clean:declarations": "rm -rf $(find . -maxdepth 2 -type f -name '*.d.ts*')", 33 | "clean": "run-p clean:*", 34 | "prepare": "husky", 35 | "prepublishOnly": "run-s build", 36 | "test:mocha": "c8 --reporter=lcov --reporter text mocha 'test/**/*.spec.js'", 37 | "test-ci": "run-s test:*", 38 | "test": "run-s check test:*" 39 | }, 40 | "devDependencies": { 41 | "@types/chai": "^4.3.16", 42 | "@types/chai-as-promised": "^7.1.8", 43 | "@types/mocha": "^10.0.7", 44 | "@types/node": "^18.19.40", 45 | "@voxpelli/eslint-config": "^22.1.0", 46 | "@voxpelli/tsconfig": "^12.0.1", 47 | "c8": "^10.1.2", 48 | "chai": "^4.4.1", 49 | "chai-as-promised": "^7.1.2", 50 | "eslint": "^9.7.0", 51 | "husky": "^9.0.11", 52 | "installed-check": "^9.3.0", 53 | "knip": "^5.26.0", 54 | "mocha": "^10.5.1", 55 | "npm-run-all2": "^6.2.0", 56 | "type-coverage": "^2.29.1", 57 | "typescript": "~5.5.3", 58 | "validate-conventional-commit": "^1.0.3" 59 | }, 60 | "dependencies": { 61 | "pony-cause": "^2.1.11", 62 | "undici": "^6.19.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>voxpelli/renovate-config" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test/github.spec.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | import { MockAgent, setGlobalDispatcher } from 'undici'; 4 | 5 | import { GitHubPublisher } from '../index.js'; 6 | 7 | chai.use(chaiAsPromised); 8 | 9 | chai.should(); 10 | 11 | describe('GitHubPublisher', () => { 12 | /** @type {import('undici').MockAgent} */ 13 | let mockAgent; 14 | /** @type {string} */ 15 | let token; 16 | /** @type {string} */ 17 | let user; 18 | /** @type {string} */ 19 | let repo; 20 | /** @type {string} */ 21 | let file; 22 | /** @type {string} */ 23 | let content; 24 | /** @type {string} */ 25 | let base64; 26 | /** @type {string} */ 27 | let path; 28 | /** @type {import('../index.js').GitHubPublisher} */ 29 | let publisher; 30 | /** @type {string} */ 31 | let createdSha; 32 | /** @type {{ content: { sha: string }}} */ 33 | let githubCreationResponse; 34 | 35 | beforeEach(() => { 36 | mockAgent = new MockAgent(); 37 | mockAgent.disableNetConnect(); 38 | setGlobalDispatcher(mockAgent); 39 | 40 | token = 'abc123'; 41 | user = 'username'; 42 | repo = 'repo'; 43 | file = 'test.txt'; 44 | content = 'Morbi leo risus, porta ac consectetur ac, vestibulum at.'; 45 | base64 = Buffer.from(content).toString('base64'); 46 | path = '/repos/' + user + '/' + repo + '/contents/' + file; 47 | createdSha = '95b966ae1c166bd92f8ae7d1c313e738c731dfc3'; 48 | githubCreationResponse = { content: { sha: createdSha } }; 49 | 50 | publisher = new GitHubPublisher(token, user, repo); 51 | }); 52 | 53 | afterEach(async () => { 54 | await mockAgent.close(); 55 | }); 56 | 57 | describe('retrieve', () => { 58 | it('should retrieve the content from GitHub', async () => { 59 | const sha = 'abc123'; 60 | 61 | mockAgent.get('https://api.github.com') 62 | .intercept({ path }) 63 | .reply(200, { content: base64, sha }); 64 | 65 | const result = await publisher.retrieve(file); 66 | 67 | result.should.have.property('content', content); 68 | result.should.have.property('sha', sha); 69 | }); 70 | 71 | it('should handle errors from GitHub', async () => { 72 | mockAgent.get('https://api.github.com') 73 | .intercept({ path }) 74 | .reply(400, {}); 75 | 76 | const result = await publisher.retrieve(file); 77 | 78 | result.should.equal(false); 79 | }); 80 | 81 | it('should specify branch if provided', async () => { 82 | const branch = 'foo-bar'; 83 | 84 | publisher = new GitHubPublisher(token, user, repo, branch); 85 | 86 | mockAgent.get('https://api.github.com') 87 | .intercept({ path: path + '?ref=' + branch }) 88 | .reply(200, { sha: 'abc123', content: 'old content' }); 89 | 90 | const result = await publisher.retrieve(file); 91 | 92 | result.should.not.equal(false); 93 | }); 94 | }); 95 | 96 | describe('publish', () => { 97 | it('should send the content to GitHub', async () => { 98 | mockAgent.get('https://api.github.com') 99 | // FIXME: Re-enable this Nock feature with Undici MockAgent 100 | // .matchHeader('user-agent', val => val && val[0] === user) 101 | // .matchHeader('authorization', val => val && val[0] === 'Bearer ' + token) 102 | // .matchHeader('accept', val => val && val[0] === 'application/vnd.github.v3+json') 103 | .intercept({ 104 | method: 'PUT', 105 | path, 106 | body: JSON.stringify({ 107 | message: 'new content', 108 | content: base64, 109 | }), 110 | }) 111 | .reply(201, githubCreationResponse); 112 | 113 | const result = await publisher.publish(file, content); 114 | 115 | result.should.equal(createdSha); 116 | }); 117 | 118 | it('should specify branch if provided', async () => { 119 | const branch = 'foo-bar'; 120 | 121 | publisher = new GitHubPublisher(token, user, repo, branch); 122 | 123 | mockAgent.get('https://api.github.com') 124 | .intercept({ 125 | method: 'PUT', 126 | path, 127 | body: JSON.stringify({ 128 | message: 'new content', 129 | content: base64, 130 | branch, 131 | }), 132 | }) 133 | .reply(201, githubCreationResponse); 134 | 135 | const result = await publisher.publish(file, content); 136 | 137 | result.should.equal(createdSha); 138 | }); 139 | 140 | it('should handle errors from GitHub', async () => { 141 | mockAgent.get('https://api.github.com') 142 | .intercept({ method: 'PUT', path }) 143 | .reply(400, {}); 144 | 145 | const result = await publisher.publish(file, content); 146 | 147 | result.should.equal(false); 148 | }); 149 | 150 | it('should fail on duplicate error if not forced', async () => { 151 | mockAgent.get('https://api.github.com') 152 | .intercept({ method: 'PUT', path }) 153 | .reply(422, {}); 154 | 155 | const result = await publisher.publish(file, content); 156 | 157 | result.should.equal(false); 158 | }); 159 | 160 | it('should succeed on duplicate error if forced', async () => { 161 | const sha = 'abc123'; 162 | 163 | mockAgent.get('https://api.github.com') 164 | .intercept({ 165 | method: 'PUT', 166 | path, 167 | body: JSON.stringify({ 168 | message: 'new content', 169 | content: base64, 170 | }), 171 | }) 172 | .reply(422, {}); 173 | 174 | mockAgent.get('https://api.github.com') 175 | .intercept({ method: 'GET', path }) 176 | .reply(200, { sha, content: 'old content' }); 177 | 178 | mockAgent.get('https://api.github.com') 179 | .intercept({ 180 | method: 'PUT', 181 | path, 182 | body: JSON.stringify({ 183 | message: 'new content', 184 | content: base64, 185 | sha, 186 | }), 187 | }) 188 | .reply(201, githubCreationResponse); 189 | 190 | const result = await publisher.publish(file, content, { force: true }); 191 | 192 | result.should.equal(createdSha); 193 | }); 194 | 195 | it('should accept raw buffers as content', async () => { 196 | const contentBuffer = Buffer.from('abc123'); 197 | 198 | mockAgent.get('https://api.github.com') 199 | .intercept({ 200 | method: 'PUT', 201 | path, 202 | body: JSON.stringify({ 203 | message: 'new content', 204 | content: contentBuffer.toString('base64'), 205 | }), 206 | }) 207 | .reply(201, githubCreationResponse); 208 | 209 | await publisher.publish(file, contentBuffer); 210 | }); 211 | 212 | it('should allow customizeable commit messages', async () => { 213 | publisher = new GitHubPublisher(token, user, repo); 214 | 215 | mockAgent.get('https://api.github.com') 216 | .intercept({ 217 | method: 'PUT', 218 | path, 219 | body: JSON.stringify({ 220 | message: 'foobar', 221 | content: base64, 222 | }), 223 | }) 224 | .reply(201, githubCreationResponse); 225 | 226 | const result = await publisher.publish(file, content, { message: 'foobar' }); 227 | 228 | result.should.equal(createdSha); 229 | }); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@voxpelli/tsconfig/node16.json", 3 | "files": [ 4 | "index.js" 5 | ], 6 | "include": [ 7 | "test/**/*.js" 8 | ] 9 | } 10 | --------------------------------------------------------------------------------