diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml new file mode 100644 index 0000000..ad7a6e4 --- /dev/null +++ b/.github/workflows/on-tag.yml @@ -0,0 +1,38 @@ +name: Create shortened tags + +# Only triggered on git tag push +on: + push: + tags: + - '*' + +jobs: + shorten: + name: Short tags + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + + - name: Make sure that current tag is merged to master + run: | + cd "${GITHUB_WORKSPACE}" + + git switch master + + # Returns 1 if it's not, and therefore terminates the workflow + git merge-base --is-ancestor "${GITHUB_SHA}" HEAD + + - name: Create, or update the short-name branch + run: | + cd "${GITHUB_WORKSPACE}" + + git config user.name "${GITHUB_ACTOR}" + git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" + + TAG=$(echo "${GITHUB_REF}" | awk -F/ '{print $NF}') + SHORT=$(echo "${TAG}" | tr -d v | cut -d. -f-2) + + git branch -f "${SHORT}" "${TAG}" + + REMOTE="https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git" + git push --force "${REMOTE}" "${SHORT}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ad59df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM alpine:3.10 + +RUN apk update \ + && apk add file curl jq + +COPY entrypoint.sh / + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a62b3df --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Damian Mee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c36668 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# github-release + +Github Action to create and update Github Releases, as well as upload assets to them. + +# Usage + +See [action.yml](action.yml) + +### Minimal + +```yaml +steps: +- uses: actions/checkout@v1 + +- uses: meeDamian/github-release@1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} +``` + +`token` is the only **always required** parameter to be passed to this action. Everything else can use sane defaults in some circumstances. See [arguments] to learn more. + +[arguments]: #Arguments + +### Arguments + +| name | required | description +|:----------------:|:----------:|------------- +| `token` | **always** | Github Access token. Can be accessed by using `${{ secrets.GITHUB_TOKEN }}` in the workflow file. +| `tag` | sometimes | If triggered by git tag push, tag is picked up automatically. Otherwise `tag:` has to be set. +| `commitish` | no | Commit hash this release should point to. Unnecessary, if `tag` is a git tag. Otherwise, current `master` is used. [more] +| `name` | no | Place to name the release, the more creative, the better. Defaults to the name of the tag used. [more] +| `body` | no | Place to put a longer description of the release, ex changelog, or info about contributors. Defaults to the commit message of the reference commit. [more] +| `draft` | no | Set to true to create a release, but not publish it. False by default. [more] +| `prerelease:` | no | Marks this release as a pre-release. False by default. [more] +| `files` | no | A **space-separated** list of files to be uploaded. When left empty, no files are uploaded. [More on files below] +| `gzip:` | no | Set whether to `gzip` uploaded assets, or not. Available options are: `true`, `false`, and `folders` which uploads files unchanged, but compresses directories/folders. Defaults to `true`. Note: it errors if set to `false`, and `files:` argument contains path to a directory. +| `allow_override:` | no | Allow override of release, if one with the same tag already exists. Defaults to `false` + + +[more]: https://developer.github.com/v3/repos/releases/#create-a-release +[More on files below]: #Files-syntax + +#### Files syntax + +In it's simplest form it takes a single file/folder to be compressed & uploaded: + +```yaml +with: + … + files: release/ +``` + +Each uploaded element can also be named by prefixing the path to it with: `:`, example: + +```yaml +with: + … + files: release-v1.0.0:release/ +``` + +As of Aug 2019, Github Actions doesn't support list arguments to actions, so to pass multiple files, pass them as a space-separated string. To do that in an easier to read way, [YAML multiline syntax] can be used, example: + +```yaml +with: + … + files: > + release-v1.0.0-linux:release/linux/ + release-v1.0.0-mac:release/darwin/ + release-v1.0.0-windows:release/not-supported-notice + checksums.txt +``` +[YAML multiline syntax]: https://yaml-multiline.info/ + +### Advanced example + +```yaml +steps: +- uses: actions/checkout@master + +- uses: meeDamian/github-release@0.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + tag: v1.3.6 + name: My Creative Name + body: > + This release actually changes the fabric of the reality, so be careful + while applying, as error in database migration, can irrecoverably wipe + some laws of physics. + gzip: folders + files: > + Dockerfile + action.yml + .github/ + license:LICENSE + work-flows:.github/ +``` + + +### Versioning + +As of Aug 2019, Github Actions doesn't natively understand shortened tags in `uses:` directive. + +To go around that and not do what [`git-tag-manual` calls _"The insane thing"_][insane], I'm creating permanent git tags for each release in a semver format prefixed with `v`, **as well as** maintain branches with shortened tags. + +Ex. `1.4` branch always points to the newest `v1.4.x` tag, etc. + +In practice: + +```yaml +# For exact tags +steps: + uses: meeDamian/github-release@v1.0.1 +``` +Or +```yaml +# For newest minor version 1.0 +steps: + uses: meeDamian/github-release@1.0 +``` + +Note: It's likely branches will be deprecated once Github Actions fixes its limitation. + +[insane]: https://git-scm.com/docs/git-tag#_on_re_tagging + +# License + +The scripts and documentation in this project are released under the [MIT License](LICENSE) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..c29e89c --- /dev/null +++ b/action.yml @@ -0,0 +1,65 @@ +name: Github Release create, update, and upload assets +description: Github Action to create, update, or add files to Github Releases +author: 'Damian Mee ' + +inputs: + # Exposed Github API inputs (identical to ones consumed by Github API): + # https://developer.github.com/v3/repos/releases/#create-a-release + # NOTE: No defaults set for these, to avoid override on update due to the impossibility + # of distinguishing between default, and user input. + token: + description: Github API token to be used. Quite crucial, I'm afraid. + required: true + + tag: + description: > + A tag for the release. Required UNLESS action is run on tag push (meaning: `${GITHUB_REF}` contains `ref/tag/`). + required: false + + commitish: + description: Unnecessary, if the tag provided is a git tag. If it isn't release will be made off `master`. + required: false + + name: + description: Place to name the release, the more creative, the better. + required: false + + body: + description: Place to put a longer description of the release, ex changelog, or info about contributors. + required: false + + draft: + description: Set to true to create a release, but not publish it. + required: false + + prerelease: + description: Marks this as a pre-release. + required: false + + # This action specific inputs: + files: + description: > + A space-separated(!) list of files to be uploaded. It's impossible to pass a list here, so make sure filenames + don't contain spaces in their names, or paths. You can optionally specify a custom asset name by pre-pending it + to the name like this: `asset-name.tgz:./folder-to-be-uploaded/`. + required: false + + gzip: + description: > + If set to `true` (default) compresses both files, and folders. If set to `false`, uploads files exactly as they are, but + errors on folders. If set to `folders`, uploads files as-they-are, but compresses folders. + required: false + default: true + + allow_override: + description: Set to `true` to allow for release overriding. + required: false + default: false + +runs: + using: 'docker' + image: 'Dockerfile' + +branding: + color: 'green' + icon: 'github' diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..a87d744 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,202 @@ +#!/bin/sh -l + +set -e + +# +# Input verification +# +TOKEN="${INPUT_TOKEN}" +if [ -z "${TOKEN}" ]; then + >&2 printf "\nERR: Invalid input: 'token' is required, and must be specified.\n" + >&2 printf "\tNote: It's necessary to interact with Github's API.\n\n" + >&2 printf "Try:\n" + >&2 printf "\tuses: meeDamian/github-release@TAG\n" + >&2 printf "\twith:\n" + >&2 printf "\t token: \${{ secrets.GITHUB_TOKEN }}\n" + >&2 printf "\t ...\n" + exit 1 +fi + +TAG="${INPUT_TAG}" + +# If `tag:` not provided, let's try using one available from github's context +if [ -z "${TAG}" ]; then + TAG="$(echo "${GITHUB_REF}" | awk -F/ '{print $NF}')" +fi + +# If all ways of getting the tag failed, show error +if [ -z "${TAG}" ]; then + >&2 printf "\nERR: Invalid input: 'tag' is required, and must be specified.\n" + >&2 printf "\tNote: It's used as a reference to the release.\n\n" + >&2 printf "Try:\n" + >&2 printf "\tuses: meeDamian/github-release@TAG\n" + >&2 printf "\twith:\n" + >&2 printf "\t tag: v0.0.1\n" + >&2 printf "\t ...\n" + exit 1 +fi + +# Verify that gzip: option is set to any of the allowed values +if [ "${INPUT_GZIP}" != "true" ] && [ "${INPUT_GZIP}" != "false" ] && [ "${INPUT_GZIP}" != "folders" ]; then + >&2 printf "\nERR: Invalid input: 'gzip' can only be not set, or one of: true, false, folders\n" + >&2 printf "\tNote: It defines what to do with assets before uploading them.\n\n" + >&2 printf "Try:\n" + >&2 printf "\tuses: meeDamian/github-release@TAG\n" + >&2 printf "\twith:\n" + >&2 printf "\t gzip: true\n" + >&2 printf "\t ...\n" + exit 1 +fi + +BASE_URL="https://api.github.com/repos/${GITHUB_REPOSITORY}/releases" + +# +## Check for Github Release existence +# +RELEASE_ID="$(curl -H "Authorization: token ${TOKEN}" "${BASE_URL}/tags/${TAG}" | jq -r '.id | select(. != null)')" + +if [ -n "${RELEASE_ID}" ] && [ "${INPUT_ALLOW_OVERRIDE}" != "true" ]; then + >&2 printf "\nERR: Release '%s' already exists, and overriding is not allowed.\n" "${TAG}" + >&2 printf "\tNote: Either use different 'tag:' name, or 'allow_override:'\n\n" + >&2 printf "Try:\n" + >&2 printf "\tuses: meeDamian/github-release@TAG\n" + >&2 printf "\twith:\n" + >&2 printf "\t ...\n" + >&2 printf "\t allow_override: true\n" + exit 1 +fi + + +# +## Create, or update release on Github +# +# For a given string return either `null` (if empty), or `"quoted string"` (if not) +toJsonOrNull() { + if [ -z "$1" ]; then + echo null + return + fi + + if [ "$1" = "true" ] || [ "$1" = "false" ]; then + echo "$1" + return + fi + + echo "\"$1\"" +} + +METHOD="POST" +URL="${BASE_URL}" +if [ -n "${RELEASE_ID}" ]; then + METHOD="PATCH" + URL="${URL}/${RELEASE_ID}" +fi + +# Creating the object in a PATCH-friendly way +CODE="$(jq -nc \ + --arg tag_name "${TAG}" \ + --argjson target_commitish "$(toJsonOrNull "${INPUT_COMMITISH}")" \ + --argjson name "$(toJsonOrNull "${INPUT_NAME}")" \ + --argjson body "$(toJsonOrNull "${INPUT_BODY}")" \ + --argjson draft "$(toJsonOrNull "${INPUT_DRAFT}")" \ + --argjson prerelease "$(toJsonOrNull "${INPUT_PRERELEASE}")" \ + '{$tag_name, $target_commitish, $name, $body, $draft, $prerelease} | del(.[] | nulls)' | \ + curl -s -X "${METHOD}" -d @- \ + --write-out "%{http_code}" -o "/tmp/${METHOD}.json" \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${URL}")" + +if [ "${CODE}" != "200" ] && [ "${CODE}" != "201" ]; then + >&2 printf "\n\tERR: %s to Github release has failed\n" "${METHOD}" + >&2 jq < "/tmp/${METHOD}.json" + exit 1 +fi + +RELEASE_ID="$(jq '.id' < "/tmp/${METHOD}.json")" + +# +## Handle, and prepare assets +# +if [ -z "${INPUT_FILES}" ]; then + >&2 echo "All done." + exit 0 +fi + +ASSETS="${HOME}/assets" + +mkdir -p "${ASSETS}/" + +# this loop splits files by the space +for entry in $(echo "${INPUT_FILES}" | tr ' ' '\n'); do + ASSET_NAME="${entry}" + + # Well, that needs explaining… If delimiter given in `-d` does not occur in string, `cut` always returns + # the original string, no matter what the field `-f` specifies. + # + # I'm prepanding `:` to `${entry}` in `echo` to ensure match happens, because once it does, `-f` is respected, + # and I can easily check fields, and that way: + # * `-f 2` always contains the name of the asset + # * `-f 3` is either the custom name of the asset, + # * `-f 3` is empty, and needs to be set to `-f 2` + ASSET_NAME="$(echo ":${entry}" | cut -d: -f2)" + ASSET_PATH="$(echo ":${entry}" | cut -d: -f3)" + + if [ -z "${ASSET_PATH}" ]; then + ASSET_NAME="$(basename "${entry}")" + ASSET_PATH="${entry}" + fi + + # this loop, expands possible globs + for file in ${ASSET_PATH}; do + # Error out on the only illegal combination: compression disabled, and folder provided + if [ "${INPUT_GZIP}" = "false" ] && [ -d "${file}" ]; then + >&2 printf "\nERR: Invalid configuration: 'gzip' cannot be set to 'false' while there are 'folders/' provided.\n" + >&2 printf "\tNote: Either set 'gzip: folders', or remove directories from the 'files:' list.\n\n" + >&2 printf "Try:\n" + >&2 printf "\tuses: meeDamian/github-release@TAG\n" + >&2 printf "\twith:\n" + >&2 printf "\t ...\n" + >&2 printf "\t gzip: folders\n" + >&2 printf "\t files: >\n" + >&2 printf "\t README.md\n" + >&2 printf "\t my-artifacts/\n" + exit 1 + fi + + # Just copy files, if compression not enabled for all + if [ "${INPUT_GZIP}" != "true" ] && [ -f "${file}" ]; then + cp "${file}" "${ASSETS}/${ASSET_NAME}" + continue + fi + + # In any other case compress + tar -cf "${ASSETS}/${ASSET_NAME}.tgz" "${file}" + done +done + +# At this point all assets to-be-uploaded (if any), are in `${ASSETS}/` folder +echo "Files to be uploaded to Github:" +ls "${ASSETS}/" + +UPLOAD_URL="$(echo "${BASE_URL}" | sed -e 's/api/uploads/')" + +for asset in "${ASSETS}"/*; do + FILE_NAME="$(basename "${asset}")" + + CODE="$(curl -sS -X POST \ + --write-out "%{http_code}" -o "/tmp/${FILE_NAME}.json" \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Length: $(stat -c %s "${asset}")" \ + -H "Content-Type: $(file -b --mime-type "${asset}")" \ + --upload-file "${asset}" \ + "${UPLOAD_URL}/${RELEASE_ID}/assets?name=${FILE_NAME}")" + + if [ "${CODE}" -ne "201" ]; then + >&2 printf "\n\tERR: Uploading %s to Github release has failed\n" "${FILE_NAME}" + jq < "/tmp/${FILE_NAME}.json" + exit 1 + fi +done + +>&2 echo "All done."