Compare commits

...

52 Commits

Author SHA1 Message Date
e812c4aca6 tabs to spaces 2019-09-17 23:22:34 +09:00
615fb448a2 changelog 2019-09-17 23:21:28 +09:00
77f9a4f575 update inputs.files.description 2019-09-17 23:16:32 +09:00
3af8783d41 support multi-line delimited assets. fixes #15 2019-09-17 23:14:30 +09:00
a6281eb824 Merge pull request #16 from softprops/draft-merger
Draft merger
2019-09-17 19:39:58 +09:00
fbe4f1f3ad update changelog 2019-09-17 19:38:31 +09:00
916d072e3d declare interface impl 2019-09-17 19:33:38 +09:00
ca93e708e3 typo in type name 2019-09-17 19:27:32 +09:00
85bb079141 ignore git modules again 2019-09-17 19:25:00 +09:00
46f6b8d27b rm node modules 2019-09-17 19:24:44 +09:00
fcf37b79bd iterating over responses not releases 2019-09-17 18:04:39 +09:00
a31211f9ff debug what comes back from our iterator 2019-09-17 17:53:48 +09:00
f3c225f2b8 regenerate js, log on tag miss for debugging 2019-09-17 17:12:53 +09:00
5f2e9fb50f revert me: integration testing 2019-09-17 13:10:47 +09:00
9f9f2a3ed2 merge drafts workaround. fixes #14 2019-09-16 23:51:16 +09:00
e2bd814f0a de-empahsize the past 2019-09-14 23:46:26 +09:00
6bdc1677a5 add demo screenshot 2019-09-11 19:23:42 +09:00
2a55ae4b12 add screenshot 2019-09-11 19:23:05 +09:00
d2403f49f5 empathy for those new to typescript projects 2019-09-09 22:00:45 +09:00
1b3edca894 call out cross platform support 2019-09-09 22:00:45 +09:00
23b784b656 Merge pull request #8 from softprops/cross-platform
refactor for cross platform use
2019-09-09 21:39:45 +09:00
29058f2f68 update changelog 2019-09-09 21:35:52 +09:00
d2e9e54ff2 readme note 2019-09-09 21:32:05 +09:00
bb78d54396 regenerate 2019-09-09 21:24:39 +09:00
ef96a2eb52 prettier 2019-09-09 21:20:59 +09:00
624fcca9a1 we're no longer publishing docker containers 2019-09-09 21:13:47 +09:00
e5cba7ebb2 remove docker ref 2019-09-09 21:13:04 +09:00
b95cbf8762 update gitignore with special note about github actions node wierdness 2019-09-09 21:12:13 +09:00
60358a145c remove node_modules 2019-09-09 21:10:40 +09:00
c7638c8893 start a changelog 2019-09-09 21:02:30 +09:00
6fa875df0b error handling 2019-09-09 21:00:04 +09:00
e197e56931 try try try again 2019-09-09 20:46:36 +09:00
cf2aed3a12 debug logging 2019-09-09 20:36:51 +09:00
d644a384fb document default release name 2019-09-09 20:17:51 +09:00
e48a25bf4e process import 2019-09-09 20:16:58 +09:00
6ef3c4f0b3 defaults are not just for documentation 2019-09-09 20:14:41 +09:00
67ba06729b node_modules 2019-09-09 20:03:40 +09:00
722f21acbd dump release trick specific to actions node_modules management 2019-09-09 19:53:59 +09:00
7ab8811117 debug workflow 2019-09-09 18:42:19 +09:00
e60d11aee8 remove previous artifacts 2019-09-09 18:39:58 +09:00
54832a3b39 top level env 2019-09-09 18:26:06 +09:00
4fe7e8efaa update actions deps 2019-09-09 18:24:28 +09:00
d30d20cc01 remove outdated comment 2019-09-09 18:16:35 +09:00
b70b9666e8 remove fs import from main 2019-09-09 18:15:01 +09:00
2940cbc2ff dont import star 2019-09-09 18:14:17 +09:00
9e07fb233a update gitignore 2019-09-09 18:08:33 +09:00
212db5146b align package.json version with repo tag 2019-09-09 18:04:49 +09:00
d27570c9ab move env to top level 2019-09-09 17:36:46 +09:00
cf0f3ebbb1 prevent github from assuming docker 2019-09-09 17:24:40 +09:00
b241ff3653 fix workflow test 2019-09-09 17:11:42 +09:00
18daf2c63f refactor for cross platform use 2019-09-09 17:10:07 +09:00
090932e783 Update README.md 2019-09-06 11:30:21 +09:00
27 changed files with 5985 additions and 2309 deletions

View File

@ -1,3 +0,0 @@
.github
target/rls
tests

View File

@ -1,9 +1,6 @@
name: Main
on:
push:
branches:
- 'master'
on: [pull_request, push]
jobs:
build:
@ -14,20 +11,16 @@ jobs:
uses: actions/checkout@master
with:
fetch-depth: 1
# https://github.com/actions/docker/tree/master/cli
- name: Package
uses: actions/docker/cli@master
with:
args: build -t ${{ github.repository }}:latest -t ${{ github.repository }}:${{ github.sha }} .
# https://github.com/actions/docker/tree/master/login
- name: Publish Auth
uses: actions/docker/login@master
env:
# https://help.github.com/en/articles/virtual-environments-for-github-actions#creating-and-using-secrets-encrypted-variables
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
# https://github.com/actions/docker/tree/master/cli
- name: Publish
uses: actions/docker/cli@master
with:
args: push ${{ github.repository }}:latest
- name: Install
run: npm ci
- name: Build
run: npm run build
- name: Test
run: npm run test
- name: "check for uncommitted changes"
# Ensure no changes, but ignore node_modules dir since dev/fresh ci deps installed.
run: |
git diff --exit-code --stat -- . ':!node_modules' \
|| (echo "##[error] found changed files after build. please 'npm run build && npm run format'" \
"and check in all changes" \
&& exit 1)

6
.gitignore vendored
View File

@ -1,2 +1,4 @@
/target
**/*.rs.bk
__tests__/runner/*
# actions requires a node_modules dir https://github.com/actions/toolkit/blob/master/docs/javascript-action.md#publish-a-releasesv1-action
# but its recommended not to check these in https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#recommendations
node_modules

37
CHANGELOG.md Normal file
View File

@ -0,0 +1,37 @@
## 0.1.2
* Add support for merging draft releases [#16](https://github.com/softprops/action-gh-release/pull/16)
GitHub's api doesn't explicitly have a way of fetching a draft release by tag name which caused draft releases to appear as separate releases when used in a build matrix.
This is now fixed.
* Add support for newline-delimited asset list [#18](https://github.com/softprops/action-gh-release/pull/18)
GitHub actions inputs don't inherently support lists of things and one might like to append a list of files to include in a release. Previously this was possible using a comma-delimited list of asset path patterns to upload. You can now provide these as a newline delimieted list for better readability
```yaml
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
filea.txt
fileb.txt
filec.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
---
## 0.1.1
* Add support for publishing releases on all supported virtual hosts
You'll need to remove `docker://` prefix and use the `@v1` action tag
---
## 0.1.0
* Initial release

18
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,18 @@
## bootstrapping
This a [JavaScript](https://help.github.com/en/articles/about-actions#types-of-actions) action but uses [TypeScript](https://www.typescriptlang.org/docs/home.html) to generate that JavaScript.
You can bootstrap your envrinment with a modern version of npm and by running `npm i` at the root of this repo.
## testing
Tests can be found under under `__tests__` directory and are runnable with the `npm t` command
## source code
Source code can be found under the `src` directory. Running `npm run build` will generate the JavaScript that will run within GitHub workflows.
## formatting
A minimal attempt at keeping a consistent code style is can be applied by running `npm run fmt`

1854
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
[package]
name = "action-gh-release"
version = "0.1.0"
authors = ["softprops <d.tangren@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
mime = "0.3"
mime_guess = "2.0"
env_logger = "0.6"
log = "0.4"
glob = "0.3"
envy = "0.4"
# hack https://docs.rs/openssl/0.10.24/openssl/#vendored
openssl = { version = "0.10", features = ["vendored"] }
reqwest = { version = "0.9", features = ["rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@ -1,42 +0,0 @@
# https://hub.docker.com/_/rust?tab=tags
FROM rust:1.37.0 as builder
# musl-gcc
RUN apt-get update \
&& apt-get install -y \
musl-dev \
musl-tools \
ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN rustup target add x86_64-unknown-linux-musl
# cache deps https://blog.jawg.io/docker-multi-stage-build/
# RUN USER=root cargo init
# COPY Cargo.toml .
# RUN cargo build --target x86_64-unknown-linux-musl --release
# COPY src src
COPY . .
RUN cargo build --target x86_64-unknown-linux-musl --release
RUN strip /app/target/x86_64-unknown-linux-musl/release/action-gh-release
FROM scratch
# https://help.github.com/en/articles/metadata-syntax-for-github-actions#about-yaml-syntax-for-github-actions
LABEL version="0.1.0" \
repository="https://github.com/meetup/action-gh-release/" \
homepage="https://github.com/meetup/action-gh-release" \
maintainer="Meetup" \
"com.github.actions.name"="GH-Release" \
"com.github.actions.description"="Creates a new Github Release" \
"com.github.actions.icon"="package" \
"com.github.actions.color"="green"
COPY --from=builder \
/etc/ssl/certs/ca-certificates.crt \
/etc/ssl/certs/
COPY --from=builder \
/app/target/x86_64-unknown-linux-musl/release/action-gh-release \
/action-gh-release
ENTRYPOINT ["/action-gh-release"]

View File

@ -1,7 +1,10 @@
# action gh-release [![](https://github.com/softprops/action-gh-release/workflows/Main/badge.svg)](https://github.com/softprops/action-gh-release/actions)
> A GitHub Action for creating GitHub Releases
> A GitHub Action for creating GitHub Releases on Linux, Windows, and OSX virtual environments
![Screenshot](demo.png)
> **⚠️ Note:** To use this action, you must have access to the [GitHub Actions](https://github.com/features/actions) feature. GitHub Actions are currently only available in public beta. You can [apply for the GitHub Actions beta here](https://github.com/features/actions/signup/).
@ -27,12 +30,34 @@ jobs:
- name: Checkout
uses: actions/checkout@master
- name: Release
uses: docker://softprops/action-gh-release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
You can also use push config tag filter
```yaml
name: Main
on:
push:
tags:
- 'v*.*.*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@master
- name: Release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
### ⬆️ Uploading release assets
@ -40,7 +65,7 @@ You can can configure a number of options for your
GitHub release and all are optional.
A common case for GitHub releases is to upload your binary after its been validated and packaged.
Use the `with.files` input to declare a comma-separated list of glob expressions matching the files
Use the `with.files` input to declare a newline-delimited list of glob expressions matching the files
you wish to upload to GitHub releases. If you'd like you can just list the files by name directly.
Below is an example of uploading a single asset named `Release.txt`
@ -61,7 +86,7 @@ jobs:
- name: Test
run: cat Release.txt
- name: Release
uses: docker://softprops/action-gh-release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: Release.txt
@ -69,6 +94,36 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
Below is an example of uploading more than one asset with a GitHub release
```yaml
name: Main
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@master
- name: Build
run: echo ${{ github.sha }} > Release.txt
- name: Test
run: cat Release.txt
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
Release.txt
LICENSE
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
> **⚠️ Note:** Notice the `|` in the yaml syntax above ☝️. That let's you effectively declare a multi-line yaml string. You can learn more about multi-line yaml syntax [here](https://yaml-multiline.info)
### 📝 External release notes
Many systems exist that can help generate release notes for you. This action supports
@ -86,10 +141,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@master
- name: Generate Changeload
- name: Generate Changelog
run: echo "# Good things have arrived" > ${{ github.workflow }}-CHANGELOG.txt
- name: Release
uses: docker://softprops/action-gh-release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
body_path: ${{ github.workflow }}-CHANGELOG.txt
@ -108,7 +163,7 @@ The following are optional as `step.with` keys
| `body` | String | Text communicating notable changes in this release |
| `body_path` | String | Path to load text communicating notable changes in this release |
| `draft` | Boolean | Indicator of whether or not this release is a draft |
| `files` | String | Comma-delimited globs of paths to assets to upload for release |
| `files` | String | Newline-delimited globs of paths to assets to upload for release |
| `name` | String | Name of the release. defaults to tag name |
💡When providing a `body` and `body_path` at the same time, `body_path` will be attempted first, then falling back on `body` if the path can not be read from.
@ -121,4 +176,7 @@ The following are *required* as `step.env` keys
|----------------|--------------------------------------|
| `GITHUB_TOKEN` | GITHUB_TOKEN as provided by `secrets`|
Doug Tangren (softprops) 2019
> **⚠️ Note:** This action was previously implemented as a docker container, limiting its use to GitHub Actions Linux virtual environments only. With recent releases, we now support cross platform usage. You'll need to remove the `docker://` prefix in these versions
Doug Tangren (softprops) 2019

25
__tests__/github.test.ts Normal file
View File

@ -0,0 +1,25 @@
//import * as assert from "assert";
//const assert = require('assert');
import * as assert from "assert";
import { mimeOrDefault, asset } from "../src/github";
describe("github", () => {
describe("mimeOrDefault", () => {
it("returns a specific mime for common path", async () => {
assert.equal(mimeOrDefault("foo.tar.gz"), "application/gzip");
});
it("returns default mime for uncommon path", async () => {
assert.equal(mimeOrDefault("foo.uncommon"), "application/octet-stream");
});
});
describe("asset", () => {
it("derives asset info from a path", async () => {
const { name, mime, size, file } = asset("tests/data/foo/bar.txt");
assert.equal(name, "bar.txt");
assert.equal(mime, "text/plain");
assert.equal(size, 10);
assert.equal(file.toString(), "release me");
});
});
});

49
__tests__/util.test.ts Normal file
View File

@ -0,0 +1,49 @@
import { isTag, paths, parseConfig, parseInputFiles } from "../src/util";
import * as assert from "assert";
describe("util", () => {
describe("parseInputFiles", () => {
it("parses empty strings", () => {
assert.deepStrictEqual(parseInputFiles(""), []);
});
it("parses comma-delimited strings", () => {
assert.deepStrictEqual(parseInputFiles("foo,bar"), ["foo", "bar"]);
});
it("parses newline and comma-delimited (and then some)", () => {
assert.deepStrictEqual(
parseInputFiles("foo,bar\nbaz,boom,\n\ndoom,loom "),
["foo", "bar", "baz", "boom", "doom", "loom"]
);
});
});
describe("parseConfig", () => {
it("parses basic config", () => {
assert.deepStrictEqual(parseConfig({}), {
github_ref: "",
github_repository: "",
github_token: "",
input_body: undefined,
input_body_path: undefined,
input_draft: false,
input_files: [],
input_name: undefined
});
});
});
describe("isTag", () => {
it("returns true for tags", async () => {
assert.equal(isTag("refs/tags/foo"), true);
});
it("returns false for other kinds of refs", async () => {
assert.equal(isTag("refs/heads/master"), false);
});
});
describe("paths", () => {
it("resolves files given a set of paths", async () => {
assert.deepStrictEqual(paths(["tests/data/**/*"]), [
"tests/data/foo/bar.txt"
]);
});
});
});

View File

@ -6,28 +6,23 @@ inputs:
body:
description: 'Note-worthy description of changes in release'
required: false
default: 'empty'
body-path:
description: 'Path to load note-worthy description of changes in release from'
required: false
default: 'empty'
name:
description: 'Gives the release a custom name'
description: 'Gives the release a custom name. Defaults to tag name'
required: false
default: 'Name of tag'
draft:
description: 'Creates a draft release'
required: false
default: 'false'
files:
description: 'Comma-delimited list of path globs for asset files to upload'
description: 'Newline-delimited list of path globs for asset files to upload'
required: false
default: 'empty'
env:
'GITHUB_TOKEN': 'As provided by Github Actions'
runs:
using: 'docker'
image: 'docker://softprops:action-gh-action'
env:
'GITHUB_TOKEN': 'As provided by Github Actions'
using: 'node12'
main: 'lib/main.js'
branding:
color: 'green'
color: 'green'
icon: 'package'

BIN
demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

11
jest.config.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'ts'],
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testRunner: 'jest-circus/runner',
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true
}

125
lib/github.js Normal file
View File

@ -0,0 +1,125 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __asyncValues = (this && this.__asyncValues) || function (o) {
if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
var m = o[Symbol.asyncIterator], i;
return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = require("fs");
const mime_1 = require("mime");
const path_1 = require("path");
class GitHubReleaser {
constructor(github) {
this.github = github;
}
getReleaseByTag(params) {
return this.github.repos.getReleaseByTag(params);
}
createRelease(params) {
return this.github.repos.createRelease(params);
}
allReleases(params) {
return this.github.paginate.iterator(this.github.repos.listReleases.endpoint.merge(params));
}
}
exports.GitHubReleaser = GitHubReleaser;
exports.asset = (path) => {
return {
name: path_1.basename(path),
mime: exports.mimeOrDefault(path),
size: fs_1.lstatSync(path).size,
file: fs_1.readFileSync(path)
};
};
exports.mimeOrDefault = (path) => {
return mime_1.getType(path) || "application/octet-stream";
};
exports.upload = (gh, url, path) => __awaiter(void 0, void 0, void 0, function* () {
let { name, size, mime, file } = exports.asset(path);
console.log(`⬆️ Uploading ${name}...`);
return yield gh.repos.uploadReleaseAsset({
url,
headers: {
"content-length": size,
"content-type": mime
},
name,
file
});
});
exports.release = (config, releaser) => __awaiter(void 0, void 0, void 0, function* () {
var e_1, _a;
const [owner, repo] = config.github_repository.split("/");
const tag = config.github_ref.replace("refs/tags/", "");
try {
// you can't get a an existing draft by tag
// so we must find one in the list of all releases
if (config.input_draft) {
try {
for (var _b = __asyncValues(releaser.allReleases({
owner,
repo
})), _c; _c = yield _b.next(), !_c.done;) {
const response = _c.value;
let release = response.data.find(release => release.tag_name === tag);
if (release) {
return release;
}
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (_c && !_c.done && (_a = _b.return)) yield _a.call(_b);
}
finally { if (e_1) throw e_1.error; }
}
}
let release = yield releaser.getReleaseByTag({
owner,
repo,
tag
});
return release.data;
}
catch (error) {
if (error.status === 404) {
try {
const tag_name = tag;
const name = config.input_name || tag;
const body = config.input_body;
const draft = config.input_draft;
console.log(`👩‍🏭 Creating new GitHub release for tag ${tag_name}...`);
let release = yield releaser.createRelease({
owner,
repo,
tag_name,
name,
body,
draft
});
return release.data;
}
catch (error) {
// presume a race with competing metrix runs
console.log(`⚠️ GitHub release failed with status: ${error.status}, retrying...`);
return exports.release(config, releaser);
}
}
else {
console.log(`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`);
throw error;
}
}
});

38
lib/main.js Normal file
View File

@ -0,0 +1,38 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
const util_1 = require("./util");
const github_1 = require("./github");
const core_1 = require("@actions/core");
const github_2 = require("@actions/github");
const process_1 = require("process");
function run() {
return __awaiter(this, void 0, void 0, function* () {
try {
const config = util_1.parseConfig(process_1.env);
if (!util_1.isTag(config.github_ref)) {
throw new Error(`⚠️ GitHub Releases requires a tag`);
}
const gh = new github_2.GitHub(config.github_token);
let rel = yield github_1.release(config, new github_1.GitHubReleaser(gh));
if (config.input_files) {
util_1.paths(config.input_files).forEach((path) => __awaiter(this, void 0, void 0, function* () {
yield github_1.upload(gh, rel.upload_url, path);
}));
}
console.log(`🎉 Release ready at ${rel.html_url}`);
}
catch (error) {
core_1.setFailed(error.message);
}
});
}
run();

37
lib/util.js Normal file
View File

@ -0,0 +1,37 @@
"use strict";
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const glob = __importStar(require("glob"));
const fs_1 = require("fs");
exports.parseInputFiles = (files) => {
return files.split(/\r?\n/).reduce((acc, line) => acc
.concat(line.split(","))
.filter(pat => pat)
.map(pat => pat.trim()), []);
};
exports.parseConfig = (env) => {
return {
github_token: env.GITHUB_TOKEN || "",
github_ref: env.GITHUB_REF || "",
github_repository: env.GITHUB_REPOSITORY || "",
input_name: env.INPUT_NAME,
input_body: env.INPUT_BODY,
input_body_path: env.INPUT_BODY_PATH,
input_files: exports.parseInputFiles(env.INPUT_FILES || ""),
input_draft: env.INPUT_DRAFT === "true"
};
};
exports.paths = (patterns) => {
return patterns.reduce((acc, pattern) => {
return acc.concat(glob.sync(pattern).filter(path => fs_1.lstatSync(path).isFile()));
}, []);
};
exports.isTag = (ref) => {
return ref.startsWith("refs/tags/");
};

5192
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "action-gh-release",
"version": "0.1.1",
"private": true,
"description": "GitHub Action for creating GitHub Releases",
"main": "lib/main.js",
"scripts": {
"build": "tsc",
"test": "jest",
"fmt": "prettier --write 'src/**/*.ts' '__tests__/**/*.ts'"
},
"repository": {
"type": "git",
"url": "git+https://github.com/softprops/action-gh-template.git"
},
"keywords": [
"actions"
],
"author": "softprops",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.1.0",
"@actions/github": "^1.1.0",
"glob": "^7.1.4",
"mime": "^2.4.4"
},
"devDependencies": {
"@types/glob": "^7.1.1",
"@types/jest": "^24.0.13",
"@types/mime": "^2.0.1",
"@types/node": "^12.7.4",
"jest": "^24.8.0",
"jest-circus": "^24.7.1",
"prettier": "1.18.2",
"ts-jest": "^24.0.2",
"typescript": "^3.5.1",
"typescript-formatter": "^7.2.2"
}
}

17
release.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
# actions requires a node_modules dir https://github.com/actions/toolkit/blob/master/docs/javascript-action.md#publish-a-releasesv1-action
# but its recommended not to check these in https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#recommendations
# as such the following hack is how we dill with it
if [[ $# -ne 1 ]]; then
echo "please pass a release version. i.e. $0 v1"
exit 1
fi
git checkout -b releases/$1 # If this branch already exists, omit the -b flag
rm -rf node_modules
sed -i '/node_modules/d' .gitignore # Bash command that removes node_modules from .gitignore
npm install --production
git add node_modules .gitignore
git commit -m node_modules
git push origin releases/$1

View File

@ -1,4 +0,0 @@
# https://github.com/rust-lang/rustfmt/blob/master/Configurations.md#fn_args_layout
fn_args_layout = "Vertical"
# https://github.com/rust-lang/rustfmt/blob/master/Configurations.md#merge_imports
merge_imports = true

View File

@ -1,111 +0,0 @@
use mime::Mime;
use reqwest::{Body, Client, StatusCode};
use serde::{Deserialize, Serialize};
use std::{error::Error, fs::File};
#[derive(Serialize, Default, Debug, PartialEq)]
pub struct Release {
pub tag_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<bool>,
}
#[derive(Deserialize)]
pub struct ReleaseResponse {
pub id: usize,
pub html_url: String,
}
pub trait Releaser {
fn release(
&self,
github_token: &str,
github_repo: &str,
release: Release,
) -> Result<ReleaseResponse, Box<dyn Error>>;
}
pub trait AssetUploader<A: Into<Body> = File> {
fn upload(
&self,
github_token: &str,
github_repo: &str,
release_id: usize,
name: &str,
mime: Mime,
asset: A,
) -> Result<StatusCode, Box<dyn Error>>;
}
impl Releaser for Client {
// https://developer.github.com/v3/repos/releases/#create-a-release
// https://developer.github.com/v3/repos/releases/#edit-a-release
fn release(
&self,
github_token: &str,
github_repo: &str,
release: Release,
) -> Result<ReleaseResponse, Box<dyn Error>> {
let endpoint = format!("https://api.github.com/repos/{}/releases", github_repo);
let mut existing = self
.get(&format!("{}/tags/{}", endpoint, release.tag_name))
.header("Authorization", format!("bearer {}", github_token))
.send()?;
match existing.status() {
StatusCode::NOT_FOUND => Ok(self
.post(&format!(
"https://api.github.com/repos/{}/releases",
github_repo
))
.header("Authorization", format!("bearer {}", github_token))
.json(&release)
.send()?
.json()?),
_ => Ok(self
.patch(&format!(
"https://api.github.com/repos/{}/releases/{}",
github_repo,
existing.json::<ReleaseResponse>()?.id
))
.header("Authorization", format!("bearer {}", github_token))
.json(&release)
.send()?
.json()?),
}
}
}
impl<A: Into<Body>> AssetUploader<A> for Client {
// https://developer.github.com/v3/repos/releases/#upload-a-release-asset
fn upload(
&self,
github_token: &str,
github_repo: &str,
release_id: usize,
name: &str,
mime: mime::Mime,
asset: A,
) -> Result<StatusCode, Box<dyn Error>> {
Ok(self
.post(&format!(
"https://uploads.github.com/repos/{}/releases/{}/assets",
github_repo, release_id
))
.header("Authorization", format!("bearer {}", github_token))
.header("Content-Type", mime.to_string())
.query(&[("name", name)])
.body(asset)
.send()?
.status())
}
}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {}
}

165
src/github.ts Normal file
View File

@ -0,0 +1,165 @@
import { GitHub } from "@actions/github";
import { Config } from "./util";
import { lstatSync, readFileSync } from "fs";
import { getType } from "mime";
import { basename } from "path";
export interface ReleaseAsset {
name: string;
mime: string;
size: number;
file: Buffer;
}
export interface Release {
upload_url: string;
html_url: string;
tag_name: string;
}
export interface Releaser {
getReleaseByTag(params: {
owner: string;
repo: string;
tag: string;
}): Promise<{ data: Release }>;
createRelease(params: {
owner: string;
repo: string;
tag_name: string;
name: string;
body: string | undefined;
draft: boolean | undefined;
}): Promise<{ data: Release }>;
allReleases(params: {
owner: string;
repo: string;
}): AsyncIterableIterator<{ data: Release[] }>;
}
export class GitHubReleaser implements Releaser {
github: GitHub;
constructor(github: GitHub) {
this.github = github;
}
getReleaseByTag(params: {
owner: string;
repo: string;
tag: string;
}): Promise<{ data: Release }> {
return this.github.repos.getReleaseByTag(params);
}
createRelease(params: {
owner: string;
repo: string;
tag_name: string;
name: string;
body: string | undefined;
draft: boolean | undefined;
}): Promise<{ data: Release }> {
return this.github.repos.createRelease(params);
}
allReleases(params: {
owner: string;
repo: string;
}): AsyncIterableIterator<{ data: Release[] }> {
return this.github.paginate.iterator(
this.github.repos.listReleases.endpoint.merge(params)
);
}
}
export const asset = (path: string): ReleaseAsset => {
return {
name: basename(path),
mime: mimeOrDefault(path),
size: lstatSync(path).size,
file: readFileSync(path)
};
};
export const mimeOrDefault = (path: string): string => {
return getType(path) || "application/octet-stream";
};
export const upload = async (
gh: GitHub,
url: string,
path: string
): Promise<any> => {
let { name, size, mime, file } = asset(path);
console.log(`⬆️ Uploading ${name}...`);
return await gh.repos.uploadReleaseAsset({
url,
headers: {
"content-length": size,
"content-type": mime
},
name,
file
});
};
export const release = async (
config: Config,
releaser: Releaser
): Promise<Release> => {
const [owner, repo] = config.github_repository.split("/");
const tag = config.github_ref.replace("refs/tags/", "");
try {
// you can't get a an existing draft by tag
// so we must find one in the list of all releases
if (config.input_draft) {
for await (const response of releaser.allReleases({
owner,
repo
})) {
let release = response.data.find(release => release.tag_name === tag);
if (release) {
return release;
}
}
}
let release = await releaser.getReleaseByTag({
owner,
repo,
tag
});
return release.data;
} catch (error) {
if (error.status === 404) {
try {
const tag_name = tag;
const name = config.input_name || tag;
const body = config.input_body;
const draft = config.input_draft;
console.log(`👩‍🏭 Creating new GitHub release for tag ${tag_name}...`);
let release = await releaser.createRelease({
owner,
repo,
tag_name,
name,
body,
draft
});
return release.data;
} catch (error) {
// presume a race with competing metrix runs
console.log(
`⚠️ GitHub release failed with status: ${error.status}, retrying...`
);
return release(config, releaser);
}
} else {
console.log(
`⚠️ Unexpected error fetching GitHub release for tag ${config.github_ref}: ${error}`
);
throw error;
}
}
};

View File

@ -1,232 +0,0 @@
mod github;
use github::{AssetUploader, Release, ReleaseResponse, Releaser};
use mime::Mime;
use reqwest::Client;
use serde::Deserialize;
use std::{
error::Error,
ffi::OsStr,
fs::{read_to_string, File},
path::{Path, PathBuf},
};
type BoxError = Box<dyn Error>;
#[derive(Deserialize, Default, Debug, PartialEq, Clone)]
struct Config {
// github provided
github_token: String,
github_ref: String,
github_repository: String,
// user provided
input_name: Option<String>,
input_body: Option<String>,
input_body_path: Option<PathBuf>,
input_files: Option<Vec<String>>,
input_draft: Option<bool>,
}
impl Into<Release> for Config {
fn into(self) -> Release {
let Config {
github_ref,
input_name,
input_body,
input_body_path,
input_draft,
..
} = self;
let tag_name = github_ref.trim_start_matches("refs/tags/").to_string();
let name = input_name.clone().or_else(|| Some(tag_name.clone()));
let draft = input_draft;
let body = input_body_path
.and_then(|path| read_to_string(path).ok())
.or_else(|| input_body.clone());
Release {
tag_name,
name,
body,
draft,
}
}
}
fn is_tag<R>(gitref: R) -> bool
where
R: AsRef<str>,
{
gitref.as_ref().starts_with("refs/tags/")
}
fn mime_or_default<P>(path: P) -> Mime
where
P: AsRef<Path>,
{
mime_guess::from_path(path).first_or(mime::APPLICATION_OCTET_STREAM)
}
fn paths<P>(
patterns: impl IntoIterator<Item = P>
) -> Result<impl IntoIterator<Item = PathBuf>, BoxError>
where
P: AsRef<str>,
{
patterns
.into_iter()
.try_fold(Vec::new(), |mut paths, pattern| {
let matched = glob::glob(pattern.as_ref())?
.filter_map(Result::ok)
.filter(|p| p.is_file());
paths.extend(matched);
Ok(paths)
})
}
fn run(
conf: Config,
releaser: &dyn Releaser,
uploader: &dyn AssetUploader,
) -> Result<(), BoxError> {
if !is_tag(&conf.github_ref) {
eprintln!("⚠️ GitHub Releases requires a tag");
return Ok(());
}
let ReleaseResponse { id, html_url } = releaser.release(
conf.github_token.as_str(),
conf.github_repository.as_str(),
conf.clone().into(),
)?;
if let Some(patterns) = conf.input_files {
for path in paths(patterns)? {
let name = &path
.file_name()
.and_then(OsStr::to_str)
.unwrap_or_else(|| "UnknownFile");
println!("⬆️ Uploading {}...", name);
let status = uploader.upload(
conf.github_token.as_str(),
conf.github_repository.as_str(),
id,
name,
mime_or_default(&path),
File::open(&path)?,
)?;
if !status.is_success() {
println!("⚠️ Failed uploading {} with error {}", name, status);
}
}
}
println!("🎉 Release ready at {}", html_url);
Ok(())
}
fn main() -> Result<(), BoxError> {
env_logger::init();
let client = Client::new();
run(envy::from_env()?, &client, &client)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mime_or_default_defaults_to_octect_stream() {
assert_eq!(
mime_or_default("umbiguous-file"),
mime::APPLICATION_OCTET_STREAM
)
}
#[test]
fn release_constructs_a_release_from_a_config() -> Result<(), BoxError> {
for (conf, expect) in vec![
(
Config {
github_ref: "refs/tags/v1.0.0".into(),
..Config::default()
},
Release {
tag_name: "v1.0.0".into(),
name: Some("v1.0.0".into()),
..Release::default()
},
),
(
Config {
github_ref: "refs/tags/v1.0.0".into(),
input_name: Some("custom".into()),
..Config::default()
},
Release {
tag_name: "v1.0.0".into(),
name: Some("custom".into()),
..Release::default()
},
),
(
Config {
github_ref: "refs/tags/v1.0.0".into(),
input_body: Some("fallback".into()),
input_body_path: Some("tests/data/foo/bar.txt".into()),
..Config::default()
},
Release {
tag_name: "v1.0.0".into(),
name: Some("v1.0.0".into()),
body: Some("release me".into()),
..Release::default()
},
),
] {
assert_eq!(expect, conf.into());
}
Ok(())
}
#[test]
fn is_tag_checks_refs() {
for (gitref, expect) in &[("refs/tags/foo", true), ("refs/heads/master", false)] {
assert_eq!(is_tag(gitref), *expect)
}
}
#[test]
fn paths_resolves_pattern_to_file_paths() -> Result<(), BoxError> {
assert_eq!(paths(vec!["tests/data/**/*"])?.into_iter().count(), 1);
Ok(())
}
#[test]
fn config_is_parsed_from_env() -> Result<(), BoxError> {
for (env, expect) in vec![(
vec![
("GITHUB_TOKEN".into(), "123".into()),
("GITHUB_REF".into(), "refs/tags/v1.0.0".into()),
("GITHUB_REPOSITORY".into(), "foo/bar".into()),
("INPUT_NAME".into(), "test release".into()),
("INPUT_BODY".into(), ":)".into()),
("INPUT_FILES".into(), "*.md".into()),
("INPUT_DRAFT".into(), "true".into()),
("INPUT_BODY_PATH".into(), "tests/data/foo/bar.txt".into()),
],
Config {
github_token: "123".into(),
github_ref: "refs/tags/v1.0.0".into(),
github_repository: "foo/bar".into(),
input_name: Some("test release".into()),
input_body: Some(":)".into()),
input_body_path: Some("tests/data/foo/bar.txt".into()),
input_files: Some(vec!["*.md".into()]),
input_draft: Some(true),
},
)] {
assert_eq!(expect, envy::from_iter::<_, Config>(env)?)
}
Ok(())
}
}

26
src/main.ts Normal file
View File

@ -0,0 +1,26 @@
import { paths, parseConfig, isTag } from "./util";
import { release, upload, GitHubReleaser } from "./github";
import { setFailed } from "@actions/core";
import { GitHub } from "@actions/github";
import { env } from "process";
async function run() {
try {
const config = parseConfig(env);
if (!isTag(config.github_ref)) {
throw new Error(`⚠️ GitHub Releases requires a tag`);
}
const gh = new GitHub(config.github_token);
let rel = await release(config, new GitHubReleaser(gh));
if (config.input_files) {
paths(config.input_files).forEach(async path => {
await upload(gh, rel.upload_url, path);
});
}
console.log(`🎉 Release ready at ${rel.html_url}`);
} catch (error) {
setFailed(error.message);
}
}
run();

52
src/util.ts Normal file
View File

@ -0,0 +1,52 @@
import * as glob from "glob";
import { lstatSync } from "fs";
export interface Config {
github_token: string;
github_ref: string;
github_repository: string;
// user provided
input_name?: string;
input_body?: string;
input_body_path?: string;
input_files?: string[];
input_draft?: boolean;
}
type Env = { [key: string]: string | undefined };
export const parseInputFiles = (files: string): string[] => {
return files.split(/\r?\n/).reduce<string[]>(
(acc, line) =>
acc
.concat(line.split(","))
.filter(pat => pat)
.map(pat => pat.trim()),
[]
);
};
export const parseConfig = (env: Env): Config => {
return {
github_token: env.GITHUB_TOKEN || "",
github_ref: env.GITHUB_REF || "",
github_repository: env.GITHUB_REPOSITORY || "",
input_name: env.INPUT_NAME,
input_body: env.INPUT_BODY,
input_body_path: env.INPUT_BODY_PATH,
input_files: parseInputFiles(env.INPUT_FILES || ""),
input_draft: env.INPUT_DRAFT === "true"
};
};
export const paths = (patterns: string[]): string[] => {
return patterns.reduce((acc: string[], pattern: string): string[] => {
return acc.concat(
glob.sync(pattern).filter(path => lstatSync(path).isFile())
);
}, []);
};
export const isTag = (ref: string): boolean => {
return ref.startsWith("refs/tags/");
};

63
tsconfig.json Normal file
View File

@ -0,0 +1,63 @@
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
},
"exclude": ["node_modules", "**/*.test.ts"]
}