feat!: add support for uploading to release drafts

When we try to get a release by tag, it doesn't
return release drafts. In order to get release
drafts we need to list the releases using a token
that has write access on the repo.

Also added tests for the same.

BREAKING CHANGE: Changed behaviour to not create
a new release. Only upload to existing releases.
Also updated the action inputs to reflect the same.

Signed-off-by: Harikrishnan Balagopal <harikrishmenon@gmail.com>
This commit is contained in:
Harikrishnan Balagopal 2020-12-08 19:23:13 +05:30
parent e74ff71f7d
commit 14e1242349
No known key found for this signature in database
GPG Key ID: 021D78C6DC6A9692
9 changed files with 14096 additions and 10068 deletions

View File

@ -14,24 +14,95 @@ jobs:
npm install npm install
npm run all npm run all
test: # make sure the action works on a clean machine without building # make sure the action works on a clean machine without building
name: E2E test test_1:
name: E2E test on draft release
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
steps: steps:
- id: get_tag
run: echo "::set-output name=new_tag::ci-test-1-${{ matrix.os }}-${{ github.run_id }}"
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- id: create_release
name: create temporary release for testing
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.get_tag.outputs.new_tag }}
release_name: Release ${{ steps.get_tag.outputs.new_tag }}
body: |
Changes in this Release
- First Change
- Second Change
draft: true
prerelease: false
- name: Make test pre-release - name: Make test pre-release
uses: ./ uses: ./
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ steps.get_tag.outputs.new_tag }}
file: README.md
asset_name: TEST.md
overwrite: true
- name: Check that the uploaded asset is readable
uses: actions/github-script@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const assert = require('assert').strict;
const release = await github.repos.getRelease({
...context.repo,
release_id: "${{ steps.create_release.outputs.id }}",
})
assert.deepStrictEqual(release.data.assets[0].name, "TEST.md")
// For a draft release, the `browser_download_url` cannot be used
// since the release hasn't been published yet.
- name: Clean up
if: ${{ always() }}
uses: actions/github-script@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.repos.deleteRelease({
...context.repo,
release_id: ${{ steps.create_release.outputs.id }},
})
test_2:
name: E2E test on published release
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- id: get_tag
run: echo "::set-output name=new_tag::ci-test-2-${{ matrix.os }}-${{ github.run_id }}"
- uses: actions/checkout@v2
- id: create_release
name: create temporary release for testing
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.get_tag.outputs.new_tag }}
release_name: Release ${{ steps.get_tag.outputs.new_tag }}
body: |
Changes in this Release
- First Change
- Second Change
draft: false
prerelease: false
- name: Make test pre-release
uses: ./
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ steps.get_tag.outputs.new_tag }}
file: README.md file: README.md
asset_name: TEST.md asset_name: TEST.md
tag: ci-test-${{ matrix.os }}-${{ github.run_id }}
overwrite: true overwrite: true
prerelease: true
body: "rofl lol test"
- name: Check that the uploaded asset is readable - name: Check that the uploaded asset is readable
uses: actions/github-script@v2 uses: actions/github-script@v2
with: with:
@ -44,10 +115,8 @@ jobs:
const expected = fs.readFileSync("README.md") const expected = fs.readFileSync("README.md")
const release = await github.repos.getReleaseByTag({ const release = await github.repos.getReleaseByTag({
...context.repo, ...context.repo,
tag: "ci-test-${{ matrix.os }}-${{ github.run_id }}", tag: "${{ steps.get_tag.outputs.new_tag }}",
}) })
assert.deepStrictEqual(release.data.prerelease, true)
assert.deepStrictEqual(release.data.body, "rofl lol test")
assert.deepStrictEqual(release.data.assets[0].name, "TEST.md") assert.deepStrictEqual(release.data.assets[0].name, "TEST.md")
const actual = child_process.execSync(`curl -Ls ${release.data.assets[0].browser_download_url}`) const actual = child_process.execSync(`curl -Ls ${release.data.assets[0].browser_download_url}`)
assert.deepStrictEqual(expected, actual) assert.deepStrictEqual(expected, actual)
@ -57,15 +126,11 @@ jobs:
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
const release = await github.repos.getReleaseByTag({
...context.repo,
tag: "ci-test-${{ matrix.os }}-${{ github.run_id }}",
})
await github.repos.deleteRelease({ await github.repos.deleteRelease({
...context.repo, ...context.repo,
release_id: release.data.id, release_id: ${{ steps.create_release.outputs.id }},
}) })
await github.git.deleteRef({ await github.git.deleteRef({
...context.repo, ...context.repo,
ref: "tags/ci-test-${{ matrix.os }}-${{ github.run_id }}", ref: "tags/${{ steps.get_tag.outputs.new_tag }}",
}) })

View File

@ -1,36 +1,50 @@
name: 'Upload files to a GitHub release' name: "Upload files to a GitHub release"
description: 'Upload files to a GitHub release (cross-platform)' description: "Upload files to a GitHub release (cross-platform)"
author: 'Sven-Hendrik Haase' author: "Sven-Hendrik Haase"
branding: branding:
icon: archive icon: archive
color: orange color: orange
inputs: inputs:
repo_token: repo_token:
description: 'GitHub token.'
required: true
file:
description: 'Local file to upload.'
required: true required: true
description: "GitHub token."
tag: tag:
description: 'Tag to use as a release.'
required: true required: true
asset_name: description: "Tag of the release."
description: 'Name of the asset. When not provided will use the file name. Unused if file_glob is set to "true".' file:
overwrite: required: true
description: 'Overwrite the release in case it already exists.' description: |
Path to the file to upload.
If this is a glob pattern then set file_glob to true.
file_glob: file_glob:
description: 'If true the file can be a glob pattern, asset_name is ignored if this is true.' required: false
prerelease: description: |
description: 'Mark the release as a pre-release. Defaults to "false".' If true the file can be a glob pattern.
release_name: asset_name will be ignored if this is true.
description: 'Explicitly set a release name. Defaults to empty which will cause the release to take the tag as name on GitHub.' default: "false"
body: asset_name:
description: 'Content of the release text. Empty by default.' required: false
description: |
By default the uploaded asset name will be same as the file name.
Use this to override the asset name. If asset_name contains the
string "$tag", it will get replaced by the release tag.
Unused if file_glob is set to true.
overwrite:
required: false
description: |
By default if an asset already exists with the same name, this action will fail.
Use this to overwrite the asset instead.
default: "false"
repo_name: repo_name:
description: 'Specify the name of the GitHub repository in which the GitHub release will be created, edited, and deleted. If the repository is other than the current, it is required to create a personal access token with `repo`, `user`, `admin:repo_hook` scopes to the foreign repository and add it as a secret. Defaults to the current repository' required: false
description: |
If the release exists in a different repository then specify its name.
It is required to create a personal access token with `repo`, `user`,
and `admin:repo_hook` scopes to the external repository and give that
as the repo_token.
outputs: outputs:
browser_download_url: browser_download_url:
description: 'The publicly available URL of the asset.' description: "The publicly available URL of the asset."
runs: runs:
using: 'node12' using: "node12"
main: 'dist/index.js' main: "dist/index.js"

19902
dist/index.js vendored

File diff suppressed because it is too large Load Diff

1
dist/index.js.map vendored Normal file

File diff suppressed because one or more lines are too long

3910
dist/sourcemap-register.js vendored Normal file

File diff suppressed because it is too large Load Diff

8
package-lock.json generated
View File

@ -1691,10 +1691,10 @@
"eslint-visitor-keys": "^1.1.0" "eslint-visitor-keys": "^1.1.0"
} }
}, },
"@zeit/ncc": { "@vercel/ncc": {
"version": "0.22.3", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/@zeit/ncc/-/ncc-0.22.3.tgz", "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.25.1.tgz",
"integrity": "sha512-jnCLpLXWuw/PAiJiVbLjA8WBC0IJQbFeUwF4I9M+23MvIxTxk5pD4Q8byQBSPmHQjz5aBoA7AKAElQxMpjrCLQ==", "integrity": "sha512-dGecC5+1wLof1MQpey4+6i2KZv4Sfs6WfXkl9KfO32GED4ZPiKxRfvtGPjbjZv0IbqMl6CxtcV1RotXYfd5SSA==",
"dev": true "dev": true
}, },
"abab": { "abab": {

View File

@ -9,8 +9,8 @@
"format": "prettier --write **/*.ts", "format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts", "format-check": "prettier --check **/*.ts",
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
"pack": "ncc build", "pack": "ncc build --source-map -o dist src/main.ts",
"test": "jest", "test": "jest --detectOpenHandles",
"all": "npm run build && npm run format && npm run lint && npm run pack && npm test" "all": "npm run build && npm run format && npm run lint && npm run pack && npm test"
}, },
"repository": { "repository": {
@ -36,7 +36,7 @@
"@types/jest": "^24.9.1", "@types/jest": "^24.9.1",
"@types/node": "^12.12.64", "@types/node": "^12.12.64",
"@typescript-eslint/parser": "^3.5.0", "@typescript-eslint/parser": "^3.5.0",
"@zeit/ncc": "^0.22.3", "@vercel/ncc": "^0.25.1",
"eslint": "^7.10.0", "eslint": "^7.10.0",
"eslint-plugin-github": "^4.0.1", "eslint-plugin-github": "^4.0.1",
"eslint-plugin-jest": "^23.17.1", "eslint-plugin-jest": "^23.17.1",

View File

@ -1,60 +1,82 @@
import * as fs from 'fs' import * as fs from 'fs'
import {Octokit} from '@octokit/core' import {Octokit} from '@octokit/core'
import {Endpoints} from '@octokit/types' import {
Endpoints,
ReposGetReleaseByTagResponseData,
ReposListReleasesResponseData
} from '@octokit/types'
import * as core from '@actions/core' import * as core from '@actions/core'
import * as github from '@actions/github' import * as github from '@actions/github'
import * as path from 'path' import * as path from 'path'
import * as glob from 'glob' import * as glob from 'glob'
type GetReleaseByTagResp = Endpoints['GET /repos/:owner/:repo/releases/tags/:tag']['response']
type ListReleasesResp = Endpoints['GET /repos/:owner/:repo/releases']['response']
type RepoAssetsResp = Endpoints['GET /repos/:owner/:repo/releases/:release_id/assets']['response'] type RepoAssetsResp = Endpoints['GET /repos/:owner/:repo/releases/:release_id/assets']['response']
type ReleaseByTagResp = Endpoints['GET /repos/:owner/:repo/releases/tags/:tag']['response']
type CreateReleaseResp = Endpoints['POST /repos/:owner/:repo/releases']['response']
type UploadAssetResp = Endpoints['POST /repos/:owner/:repo/releases/:release_id/assets{?name,label}']['response'] type UploadAssetResp = Endpoints['POST /repos/:owner/:repo/releases/:release_id/assets{?name,label}']['response']
type ReleaseData =
| ReposGetReleaseByTagResponseData
| ReposListReleasesResponseData[0]
async function get_release_by_tag( async function get_release_by_tag(
tag: string, tag: string,
prerelease: boolean,
release_name: string,
body: string,
octokit: Octokit octokit: Octokit
): Promise<ReleaseByTagResp | CreateReleaseResp> { ): Promise<ReleaseData> {
try { try {
core.debug(`Getting release by tag ${tag}.`) core.info(`Getting release by tag ${tag}`)
return await octokit.repos.getReleaseByTag({ const resp: GetReleaseByTagResp = await octokit.repos.getReleaseByTag({
...repo(), ...repo(),
tag: tag tag: tag
}) })
core.debug(`response from get release by tag: ${JSON.stringify(resp)}`)
return resp.data
} catch (error) { } catch (error) {
// If this returns 404, we need to create the release first. if (error.status !== 404) {
if (error.status === 404) { core.info(`Failed to get release by tag. Not a 404 error. Throwing.`)
core.debug(
`Release for tag ${tag} doesn't exist yet so we'll create it now.`
)
return await octokit.repos.createRelease({
...repo(),
tag_name: tag,
prerelease: prerelease,
name: release_name,
body: body
})
} else {
throw error throw error
} }
} }
// If we get a 404, we need to check the release drafts.
try {
core.info(
'Failed to get release by tag. Checking to see if a release draft with the tag exists.'
)
const resp: ListReleasesResp = await octokit.repos.listReleases(repo())
core.debug(`response from listing releases: ${JSON.stringify(resp)}`)
let found = false
let draftRelease = resp.data[0]
for (const release of resp.data) {
if (release.tag_name === tag) {
draftRelease = release
found = true
break
}
}
if (found) {
core.info('Found release draft with the given tag.')
return draftRelease
}
} catch (error) {
core.info(`Failed to list the releases. Throwing.`)
throw error
}
throw new Error(`No release or release draft found with the tag ${tag}`)
} }
async function upload_to_release( async function upload_to_release(
release: ReleaseByTagResp | CreateReleaseResp, releaseData: ReleaseData,
file: string, file: string,
asset_name: string, asset_name: string,
tag: string, tag: string,
overwrite: boolean, overwrite: boolean,
octokit: Octokit octokit: Octokit
): Promise<undefined | string> { ): Promise<string> {
const stat = fs.statSync(file) const stat = fs.statSync(file)
if (!stat.isFile()) { if (!stat.isFile()) {
core.debug(`Skipping ${file}, since its not a file`) core.info(`Skipping ${file} since it is not a file.`)
return return ''
} }
const file_size = stat.size const file_size = stat.size
const file_bytes = fs.readFileSync(file) const file_bytes = fs.readFileSync(file)
@ -62,32 +84,32 @@ async function upload_to_release(
// Check for duplicates. // Check for duplicates.
const assets: RepoAssetsResp = await octokit.repos.listReleaseAssets({ const assets: RepoAssetsResp = await octokit.repos.listReleaseAssets({
...repo(), ...repo(),
release_id: release.data.id release_id: releaseData.id
}) })
const duplicate_asset = assets.data.find(a => a.name === asset_name) const duplicate_asset = assets.data.find(a => a.name === asset_name)
if (duplicate_asset !== undefined) { if (duplicate_asset !== undefined) {
if (overwrite) { if (overwrite) {
core.debug( core.info(
`An asset called ${asset_name} already exists in release ${tag} so we'll overwrite it.` `Overwriting since an asset called ${asset_name} already exists in release ${tag}`
) )
await octokit.repos.deleteReleaseAsset({ await octokit.repos.deleteReleaseAsset({
...repo(), ...repo(),
asset_id: duplicate_asset.id asset_id: duplicate_asset.id
}) })
} else { } else {
core.setFailed(`An asset called ${asset_name} already exists.`) core.setFailed(
`Overwrite is set to false and an asset called ${asset_name} already exists in release ${tag}`
)
return duplicate_asset.browser_download_url return duplicate_asset.browser_download_url
} }
} else { } else {
core.debug( core.info(`Release ${tag} has no pre-existing asset called ${asset_name}`)
`No pre-existing asset called ${asset_name} found in release ${tag}. All good.`
)
} }
core.debug(`Uploading ${file} to ${asset_name} in release ${tag}.`) core.info(`Uploading ${file} to ${asset_name} in release ${tag}`)
const uploaded_asset: UploadAssetResp = await octokit.repos.uploadReleaseAsset( const uploaded_asset: UploadAssetResp = await octokit.repos.uploadReleaseAsset(
{ {
url: release.data.upload_url, url: releaseData.upload_url,
name: asset_name, name: asset_name,
data: file_bytes, data: file_bytes,
headers: { headers: {
@ -102,47 +124,38 @@ async function upload_to_release(
function repo(): {owner: string; repo: string} { function repo(): {owner: string; repo: string} {
const repo_name = core.getInput('repo_name') const repo_name = core.getInput('repo_name')
// If we're not targeting a foreign repository, we can just return immediately and don't have to do extra work. // If we're not targeting a foreign repository, we can just return immediately and don't have to do extra work.
if (!repo_name) { if (repo_name === '') {
return github.context.repo return github.context.repo
} }
const owner = repo_name.substr(0, repo_name.indexOf('/')) const owner = repo_name.substr(0, repo_name.indexOf('/'))
if (!owner) { if (!owner) {
throw new Error(`Could not extract 'owner' from 'repo_name': ${repo_name}.`) throw new Error(`Could not extract 'owner' from 'repo_name': ${repo_name}`)
} }
const repo = repo_name.substr(repo_name.indexOf('/') + 1) const repo = repo_name.substr(repo_name.indexOf('/') + 1)
if (!repo) { if (!repo) {
throw new Error(`Could not extract 'repo' from 'repo_name': ${repo_name}.`) throw new Error(`Could not extract 'repo' from 'repo_name': ${repo_name}`)
}
return {
owner,
repo
} }
return {owner, repo}
} }
async function run(): Promise<void> { async function run(): Promise<void> {
try { try {
// Get the inputs from the workflow file: https://github.com/actions/toolkit/tree/master/packages/core#inputsoutputs // Get the inputs from the workflow file: https://github.com/actions/toolkit/tree/master/packages/core#inputsoutputs
const token = core.getInput('repo_token', {required: true}) const token = core.getInput('repo_token', {required: true})
const file = core.getInput('file', {required: true})
const tag = core const tag = core
.getInput('tag', {required: true}) .getInput('tag', {required: true})
.replace('refs/tags/', '') .replace('refs/tags/', '')
.replace('refs/heads/', '') .replace('refs/heads/', '')
const file = core.getInput('file', {required: true})
const file_glob = core.getInput('file_glob') == 'true' ? true : false const file_glob = core.getInput('file_glob') === 'true'
const overwrite = core.getInput('overwrite') == 'true' ? true : false const asset_name =
const prerelease = core.getInput('prerelease') == 'true' ? true : false core.getInput('asset_name') === ''
const release_name = core.getInput('release_name') ? path.basename(file)
const body = core.getInput('body') : core.getInput('asset_name').replace(/\$tag/g, tag)
const overwrite = core.getInput('overwrite') === 'true'
const octokit: Octokit = github.getOctokit(token) const octokit: Octokit = github.getOctokit(token)
const release = await get_release_by_tag( const release = await get_release_by_tag(tag, octokit)
tag,
prerelease,
release_name,
body,
octokit
)
if (file_glob) { if (file_glob) {
const files = glob.sync(file) const files = glob.sync(file)
@ -163,10 +176,6 @@ async function run(): Promise<void> {
core.setFailed('No files matching the glob pattern found.') core.setFailed('No files matching the glob pattern found.')
} }
} else { } else {
const asset_name =
core.getInput('asset_name') !== ''
? core.getInput('asset_name').replace(/\$tag/g, tag)
: path.basename(file)
const asset_download_url = await upload_to_release( const asset_download_url = await upload_to_release(
release, release,
file, file,

View File

@ -2,11 +2,16 @@
"compilerOptions": { "compilerOptions": {
"target": "es6", "target": "es6",
"module": "commonjs", "module": "commonjs",
"moduleResolution": "node",
"outDir": "./lib", "outDir": "./lib",
"rootDir": "./src", "rootDir": "./src",
"strict": true, "strict": true,
"noImplicitAny": true, "noImplicitAny": true,
"esModuleInterop": true "esModuleInterop": true,
"sourceMap": true
}, },
"exclude": ["node_modules", "**/*.test.ts"] "exclude": [
"node_modules",
"**/*.test.ts"
]
} }