diff --git a/packages/cache/package-lock.json b/packages/cache/package-lock.json index 3017263a..2ec7b7d6 100644 --- a/packages/cache/package-lock.json +++ b/packages/cache/package-lock.json @@ -1,6 +1,6 @@ { "name": "@actions/cache", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/packages/cache/package.json b/packages/cache/package.json index 6ae7b318..97f774b1 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -1,6 +1,6 @@ { "name": "@actions/cache", - "version": "2.0.1", + "version": "2.0.2", "preview": true, "description": "Actions cache lib", "keywords": [ diff --git a/packages/cache/src/cache.ts b/packages/cache/src/cache.ts index 01d49f9f..86dd60c8 100644 --- a/packages/cache/src/cache.ts +++ b/packages/cache/src/cache.ts @@ -197,7 +197,7 @@ export async function saveCache( if (reserveCacheResponse?.result?.cacheId) { cacheId = reserveCacheResponse?.result?.cacheId } else if (reserveCacheResponse?.statusCode === 400) { - throw new ReserveCacheError( + throw new Error( reserveCacheResponse?.error?.message ?? `Cache size of ~${Math.round( archiveFileSize / (1024 * 1024) diff --git a/packages/core/RELEASES.md b/packages/core/RELEASES.md index df556fe2..fa0c93ae 100644 --- a/packages/core/RELEASES.md +++ b/packages/core/RELEASES.md @@ -1,5 +1,8 @@ # @actions/core Releases +### 1.7.0 +- [Added `markdownSummary` extension](https://github.com/actions/toolkit/pull/1014) + ### 1.6.0 - [Added OIDC Client function `getIDToken`](https://github.com/actions/toolkit/pull/919) - [Added `file` parameter to `AnnotationProperties`](https://github.com/actions/toolkit/pull/896) diff --git a/packages/core/__tests__/markdown-summary.test.ts b/packages/core/__tests__/markdown-summary.test.ts new file mode 100644 index 00000000..d9b8ee5c --- /dev/null +++ b/packages/core/__tests__/markdown-summary.test.ts @@ -0,0 +1,277 @@ +import * as fs from 'fs' +import * as os from 'os' +import path from 'path' +import {markdownSummary, SUMMARY_ENV_VAR} from '../src/markdown-summary' + +const testFilePath = path.join(__dirname, 'test', 'test-summary.md') + +async function assertSummary(expected: string): Promise { + const file = await fs.promises.readFile(testFilePath, {encoding: 'utf8'}) + expect(file).toEqual(expected) +} + +const fixtures = { + text: 'hello world 🌎', + code: `func fork() { + for { + go fork() + } +}`, + list: ['foo', 'bar', 'baz', '💣'], + table: [ + [ + { + data: 'foo', + header: true + }, + { + data: 'bar', + header: true + }, + { + data: 'baz', + header: true + }, + { + data: 'tall', + rowspan: '3' + } + ], + ['one', 'two', 'three'], + [ + { + data: 'wide', + colspan: '3' + } + ] + ], + details: { + label: 'open me', + content: '🎉 surprise' + }, + img: { + src: 'https://github.com/actions.png', + alt: 'actions logo', + options: { + width: '32', + height: '32' + } + }, + quote: { + text: 'Where the world builds software', + cite: 'https://github.com/about' + }, + link: { + text: 'GitHub', + href: 'https://github.com/' + } +} + +describe('@actions/core/src/markdown-summary', () => { + beforeEach(async () => { + process.env[SUMMARY_ENV_VAR] = testFilePath + await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}) + markdownSummary.emptyBuffer() + }) + + afterAll(async () => { + await fs.promises.unlink(testFilePath) + }) + + it('throws if summary env var is undefined', async () => { + process.env[SUMMARY_ENV_VAR] = undefined + const write = markdownSummary.addRaw(fixtures.text).write() + + await expect(write).rejects.toThrow() + }) + + it('throws if summary file does not exist', async () => { + await fs.promises.unlink(testFilePath) + const write = markdownSummary.addRaw(fixtures.text).write() + + await expect(write).rejects.toThrow() + }) + + it('appends text to summary file', async () => { + await fs.promises.writeFile(testFilePath, '# ', {encoding: 'utf8'}) + await markdownSummary.addRaw(fixtures.text).write() + await assertSummary(`# ${fixtures.text}`) + }) + + it('overwrites text to summary file', async () => { + await fs.promises.writeFile(testFilePath, 'overwrite', {encoding: 'utf8'}) + await markdownSummary.addRaw(fixtures.text).write({overwrite: true}) + await assertSummary(fixtures.text) + }) + + it('appends text with EOL to summary file', async () => { + await fs.promises.writeFile(testFilePath, '# ', {encoding: 'utf8'}) + await markdownSummary.addRaw(fixtures.text, true).write() + await assertSummary(`# ${fixtures.text}${os.EOL}`) + }) + + it('chains appends text to summary file', async () => { + await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}) + await markdownSummary + .addRaw(fixtures.text) + .addRaw(fixtures.text) + .addRaw(fixtures.text) + .write() + await assertSummary([fixtures.text, fixtures.text, fixtures.text].join('')) + }) + + it('empties buffer after write', async () => { + await fs.promises.writeFile(testFilePath, '', {encoding: 'utf8'}) + await markdownSummary.addRaw(fixtures.text).write() + await assertSummary(fixtures.text) + expect(markdownSummary.isEmptyBuffer()).toBe(true) + }) + + it('returns summary buffer as string', () => { + markdownSummary.addRaw(fixtures.text) + expect(markdownSummary.stringify()).toEqual(fixtures.text) + }) + + it('return correct values for isEmptyBuffer', () => { + markdownSummary.addRaw(fixtures.text) + expect(markdownSummary.isEmptyBuffer()).toBe(false) + + markdownSummary.emptyBuffer() + expect(markdownSummary.isEmptyBuffer()).toBe(true) + }) + + it('clears a buffer and summary file', async () => { + await fs.promises.writeFile(testFilePath, 'content', {encoding: 'utf8'}) + await markdownSummary.clear() + await assertSummary('') + expect(markdownSummary.isEmptyBuffer()).toBe(true) + }) + + it('adds EOL', async () => { + await markdownSummary + .addRaw(fixtures.text) + .addEOL() + .write() + await assertSummary(fixtures.text + os.EOL) + }) + + it('adds a code block without language', async () => { + await markdownSummary.addCodeBlock(fixtures.code).write() + const expected = `
func fork() {\n  for {\n    go fork()\n  }\n}
${os.EOL}` + await assertSummary(expected) + }) + + it('adds a code block with a language', async () => { + await markdownSummary.addCodeBlock(fixtures.code, 'go').write() + const expected = `
func fork() {\n  for {\n    go fork()\n  }\n}
${os.EOL}` + await assertSummary(expected) + }) + + it('adds an unordered list', async () => { + await markdownSummary.addList(fixtures.list).write() + const expected = `${os.EOL}` + await assertSummary(expected) + }) + + it('adds an ordered list', async () => { + await markdownSummary.addList(fixtures.list, true).write() + const expected = `
  1. foo
  2. bar
  3. baz
  4. 💣
${os.EOL}` + await assertSummary(expected) + }) + + it('adds a table', async () => { + await markdownSummary.addTable(fixtures.table).write() + const expected = `
foobarbaztall
onetwothree
wide
${os.EOL}` + await assertSummary(expected) + }) + + it('adds a details element', async () => { + await markdownSummary + .addDetails(fixtures.details.label, fixtures.details.content) + .write() + const expected = `
open me🎉 surprise
${os.EOL}` + await assertSummary(expected) + }) + + it('adds an image with alt text', async () => { + await markdownSummary.addImage(fixtures.img.src, fixtures.img.alt).write() + const expected = `actions logo${os.EOL}` + await assertSummary(expected) + }) + + it('adds an image with custom dimensions', async () => { + await markdownSummary + .addImage(fixtures.img.src, fixtures.img.alt, fixtures.img.options) + .write() + const expected = `actions logo${os.EOL}` + await assertSummary(expected) + }) + + it('adds an image with custom dimensions', async () => { + await markdownSummary + .addImage(fixtures.img.src, fixtures.img.alt, fixtures.img.options) + .write() + const expected = `actions logo${os.EOL}` + await assertSummary(expected) + }) + + it('adds headings h1...h6', async () => { + for (const i of [1, 2, 3, 4, 5, 6]) { + markdownSummary.addHeading('heading', i) + } + await markdownSummary.write() + const expected = `

heading

${os.EOL}

heading

${os.EOL}

heading

${os.EOL}

heading

${os.EOL}
heading
${os.EOL}
heading
${os.EOL}` + await assertSummary(expected) + }) + + it('adds h1 if heading level not specified', async () => { + await markdownSummary.addHeading('heading').write() + const expected = `

heading

${os.EOL}` + await assertSummary(expected) + }) + + it('uses h1 if heading level is garbage or out of range', async () => { + await markdownSummary + .addHeading('heading', 'foobar') + .addHeading('heading', 1337) + .addHeading('heading', -1) + .addHeading('heading', Infinity) + .write() + const expected = `

heading

${os.EOL}

heading

${os.EOL}

heading

${os.EOL}

heading

${os.EOL}` + await assertSummary(expected) + }) + + it('adds a separator', async () => { + await markdownSummary.addSeparator().write() + const expected = `
${os.EOL}` + await assertSummary(expected) + }) + + it('adds a break', async () => { + await markdownSummary.addBreak().write() + const expected = `
${os.EOL}` + await assertSummary(expected) + }) + + it('adds a quote', async () => { + await markdownSummary.addQuote(fixtures.quote.text).write() + const expected = `
Where the world builds software
${os.EOL}` + await assertSummary(expected) + }) + + it('adds a quote with citation', async () => { + await markdownSummary + .addQuote(fixtures.quote.text, fixtures.quote.cite) + .write() + const expected = `
Where the world builds software
${os.EOL}` + await assertSummary(expected) + }) + + it('adds a link with href', async () => { + await markdownSummary + .addLink(fixtures.link.text, fixtures.link.href) + .write() + const expected = `GitHub${os.EOL}` + await assertSummary(expected) + }) +}) diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 7eb2b6a7..9a638c5a 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,6 +1,6 @@ { "name": "@actions/core", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/packages/core/package.json b/packages/core/package.json index 8d7a3997..56ee8427 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@actions/core", - "version": "1.6.0", + "version": "1.7.0", "description": "Actions core lib", "keywords": [ "github", diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index fafe3e6c..8a9170ca 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -359,3 +359,8 @@ export function getState(name: string): string { export async function getIDToken(aud?: string): Promise { return await OidcClient.getIDToken(aud) } + +/** + * Markdown summary exports + */ +export {markdownSummary} from './markdown-summary' diff --git a/packages/core/src/markdown-summary.ts b/packages/core/src/markdown-summary.ts new file mode 100644 index 00000000..97d2d3ca --- /dev/null +++ b/packages/core/src/markdown-summary.ts @@ -0,0 +1,362 @@ +import {EOL} from 'os' +import {constants, promises} from 'fs' +const {access, appendFile, writeFile} = promises + +export const SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY' +export const SUMMARY_DOCS_URL = + 'https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary' + +export type SummaryTableRow = (SummaryTableCell | string)[] + +export interface SummaryTableCell { + /** + * Cell content + */ + data: string + /** + * Render cell as header + * (optional) default: false + */ + header?: boolean + /** + * Number of columns the cell extends + * (optional) default: '1' + */ + colspan?: string + /** + * Number of rows the cell extends + * (optional) default: '1' + */ + rowspan?: string +} + +export interface SummaryImageOptions { + /** + * The width of the image in pixels. Must be an integer without a unit. + * (optional) + */ + width?: string + /** + * The height of the image in pixels. Must be an integer without a unit. + * (optional) + */ + height?: string +} + +export interface SummaryWriteOptions { + /** + * Replace all existing content in summary file with buffer contents + * (optional) default: false + */ + overwrite?: boolean +} + +class MarkdownSummary { + private _buffer: string + private _filePath?: string + + constructor() { + this._buffer = '' + } + + /** + * Finds the summary file path from the environment, rejects if env var is not found or file does not exist + * Also checks r/w permissions. + * + * @returns step summary file path + */ + private async filePath(): Promise { + if (this._filePath) { + return this._filePath + } + + const pathFromEnv = process.env[SUMMARY_ENV_VAR] + if (!pathFromEnv) { + throw new Error( + `Unable to find environment variable for $${SUMMARY_ENV_VAR}. Check if your runtime environment supports markdown summaries.` + ) + } + + try { + await access(pathFromEnv, constants.R_OK | constants.W_OK) + } catch { + throw new Error( + `Unable to access summary file: '${pathFromEnv}'. Check if the file has correct read/write permissions.` + ) + } + + this._filePath = pathFromEnv + return this._filePath + } + + /** + * Wraps content in an HTML tag, adding any HTML attributes + * + * @param {string} tag HTML tag to wrap + * @param {string | null} content content within the tag + * @param {[attribute: string]: string} attrs key-value list of HTML attributes to add + * + * @returns {string} content wrapped in HTML element + */ + private wrap( + tag: string, + content: string | null, + attrs: {[attribute: string]: string} = {} + ): string { + const htmlAttrs = Object.entries(attrs) + .map(([key, value]) => ` ${key}="${value}"`) + .join('') + + if (!content) { + return `<${tag}${htmlAttrs}>` + } + + return `<${tag}${htmlAttrs}>${content}` + } + + /** + * Writes text in the buffer to the summary buffer file and empties buffer. Will append by default. + * + * @param {SummaryWriteOptions} [options] (optional) options for write operation + * + * @returns {Promise} markdown summary instance + */ + async write(options?: SummaryWriteOptions): Promise { + const overwrite = !!options?.overwrite + const filePath = await this.filePath() + const writeFunc = overwrite ? writeFile : appendFile + await writeFunc(filePath, this._buffer, {encoding: 'utf8'}) + return this.emptyBuffer() + } + + /** + * Clears the summary buffer and wipes the summary file + * + * @returns {MarkdownSummary} markdown summary instance + */ + async clear(): Promise { + return this.emptyBuffer().write({overwrite: true}) + } + + /** + * Returns the current summary buffer as a string + * + * @returns {string} string of summary buffer + */ + stringify(): string { + return this._buffer + } + + /** + * If the summary buffer is empty + * + * @returns {boolen} true if the buffer is empty + */ + isEmptyBuffer(): boolean { + return this._buffer.length === 0 + } + + /** + * Resets the summary buffer without writing to summary file + * + * @returns {MarkdownSummary} markdown summary instance + */ + emptyBuffer(): MarkdownSummary { + this._buffer = '' + return this + } + + /** + * Adds raw text to the summary buffer + * + * @param {string} text content to add + * @param {boolean} [addEOL=false] (optional) append an EOL to the raw text (default: false) + * + * @returns {MarkdownSummary} markdown summary instance + */ + addRaw(text: string, addEOL = false): MarkdownSummary { + this._buffer += text + return addEOL ? this.addEOL() : this + } + + /** + * Adds the operating system-specific end-of-line marker to the buffer + * + * @returns {MarkdownSummary} markdown summary instance + */ + addEOL(): MarkdownSummary { + return this.addRaw(EOL) + } + + /** + * Adds an HTML codeblock to the summary buffer + * + * @param {string} code content to render within fenced code block + * @param {string} lang (optional) language to syntax highlight code + * + * @returns {MarkdownSummary} markdown summary instance + */ + addCodeBlock(code: string, lang?: string): MarkdownSummary { + const attrs = { + ...(lang && {lang}) + } + const element = this.wrap('pre', this.wrap('code', code), attrs) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML list to the summary buffer + * + * @param {string[]} items list of items to render + * @param {boolean} [ordered=false] (optional) if the rendered list should be ordered or not (default: false) + * + * @returns {MarkdownSummary} markdown summary instance + */ + addList(items: string[], ordered = false): MarkdownSummary { + const tag = ordered ? 'ol' : 'ul' + const listItems = items.map(item => this.wrap('li', item)).join('') + const element = this.wrap(tag, listItems) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML table to the summary buffer + * + * @param {SummaryTableCell[]} rows table rows + * + * @returns {MarkdownSummary} markdown summary instance + */ + addTable(rows: SummaryTableRow[]): MarkdownSummary { + const tableBody = rows + .map(row => { + const cells = row + .map(cell => { + if (typeof cell === 'string') { + return this.wrap('td', cell) + } + + const {header, data, colspan, rowspan} = cell + const tag = header ? 'th' : 'td' + const attrs = { + ...(colspan && {colspan}), + ...(rowspan && {rowspan}) + } + + return this.wrap(tag, data, attrs) + }) + .join('') + + return this.wrap('tr', cells) + }) + .join('') + + const element = this.wrap('table', tableBody) + return this.addRaw(element).addEOL() + } + + /** + * Adds a collapsable HTML details element to the summary buffer + * + * @param {string} label text for the closed state + * @param {string} content collapsable content + * + * @returns {MarkdownSummary} markdown summary instance + */ + addDetails(label: string, content: string): MarkdownSummary { + const element = this.wrap('details', this.wrap('summary', label) + content) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML image tag to the summary buffer + * + * @param {string} src path to the image you to embed + * @param {string} alt text description of the image + * @param {SummaryImageOptions} options (optional) addition image attributes + * + * @returns {MarkdownSummary} markdown summary instance + */ + addImage( + src: string, + alt: string, + options?: SummaryImageOptions + ): MarkdownSummary { + const {width, height} = options || {} + const attrs = { + ...(width && {width}), + ...(height && {height}) + } + + const element = this.wrap('img', null, {src, alt, ...attrs}) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML section heading element + * + * @param {string} text heading text + * @param {number | string} [level=1] (optional) the heading level, default: 1 + * + * @returns {MarkdownSummary} markdown summary instance + */ + addHeading(text: string, level?: number | string): MarkdownSummary { + const tag = `h${level}` + const allowedTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag) + ? tag + : 'h1' + const element = this.wrap(allowedTag, text) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML thematic break (
) to the summary buffer + * + * @returns {MarkdownSummary} markdown summary instance + */ + addSeparator(): MarkdownSummary { + const element = this.wrap('hr', null) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML line break (
) to the summary buffer + * + * @returns {MarkdownSummary} markdown summary instance + */ + addBreak(): MarkdownSummary { + const element = this.wrap('br', null) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML blockquote to the summary buffer + * + * @param {string} text quote text + * @param {string} cite (optional) citation url + * + * @returns {MarkdownSummary} markdown summary instance + */ + addQuote(text: string, cite?: string): MarkdownSummary { + const attrs = { + ...(cite && {cite}) + } + const element = this.wrap('blockquote', text, attrs) + return this.addRaw(element).addEOL() + } + + /** + * Adds an HTML anchor tag to the summary buffer + * + * @param {string} text link text/content + * @param {string} href hyperlink + * + * @returns {MarkdownSummary} markdown summary instance + */ + addLink(text: string, href: string): MarkdownSummary { + const element = this.wrap('a', text, {href}) + return this.addRaw(element).addEOL() + } +} + +// singleton export +export const markdownSummary = new MarkdownSummary() diff --git a/packages/glob/RELEASES.md b/packages/glob/RELEASES.md index 274e1c24..80c6bd97 100644 --- a/packages/glob/RELEASES.md +++ b/packages/glob/RELEASES.md @@ -1,5 +1,8 @@ # @actions/glob Releases +### 0.3.0 +- Added a `verbose` option to HashFiles [#1052](https://github.com/actions/toolkit/pull/1052/files) + ### 0.2.1 - Update `lockfileVersion` to `v2` in `package-lock.json [#1023](https://github.com/actions/toolkit/pull/1023) diff --git a/packages/glob/package-lock.json b/packages/glob/package-lock.json index a5e6b871..ee9a1739 100644 --- a/packages/glob/package-lock.json +++ b/packages/glob/package-lock.json @@ -1,6 +1,6 @@ { "name": "@actions/glob", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/packages/glob/package.json b/packages/glob/package.json index 6b373b6d..58b32f7a 100644 --- a/packages/glob/package.json +++ b/packages/glob/package.json @@ -1,6 +1,6 @@ { "name": "@actions/glob", - "version": "0.2.1", + "version": "0.3.0", "preview": true, "description": "Actions glob lib", "keywords": [ diff --git a/packages/glob/src/glob.ts b/packages/glob/src/glob.ts index e1098885..acbb6d2c 100644 --- a/packages/glob/src/glob.ts +++ b/packages/glob/src/glob.ts @@ -26,12 +26,13 @@ export async function create( */ export async function hashFiles( patterns: string, - options?: HashFileOptions + options?: HashFileOptions, + verbose: Boolean = false ): Promise { let followSymbolicLinks = true if (options && typeof options.followSymbolicLinks === 'boolean') { followSymbolicLinks = options.followSymbolicLinks } const globber = await create(patterns, {followSymbolicLinks}) - return _hashFiles(globber) + return _hashFiles(globber, verbose) } diff --git a/packages/glob/src/internal-hash-files.ts b/packages/glob/src/internal-hash-files.ts index 8ca8ba68..ef18d9f5 100644 --- a/packages/glob/src/internal-hash-files.ts +++ b/packages/glob/src/internal-hash-files.ts @@ -6,19 +6,23 @@ import * as util from 'util' import * as path from 'path' import {Globber} from './glob' -export async function hashFiles(globber: Globber): Promise { +export async function hashFiles( + globber: Globber, + verbose: Boolean = false +): Promise { + const writeDelegate = verbose ? core.info : core.debug let hasMatch = false const githubWorkspace = process.env['GITHUB_WORKSPACE'] ?? process.cwd() const result = crypto.createHash('sha256') let count = 0 for await (const file of globber.globGenerator()) { - core.debug(file) + writeDelegate(file) if (!file.startsWith(`${githubWorkspace}${path.sep}`)) { - core.debug(`Ignore '${file}' since it is not under GITHUB_WORKSPACE.`) + writeDelegate(`Ignore '${file}' since it is not under GITHUB_WORKSPACE.`) continue } if (fs.statSync(file).isDirectory()) { - core.debug(`Skip directory '${file}'.`) + writeDelegate(`Skip directory '${file}'.`) continue } const hash = crypto.createHash('sha256') @@ -33,10 +37,10 @@ export async function hashFiles(globber: Globber): Promise { result.end() if (hasMatch) { - core.debug(`Found ${count} files to hash.`) + writeDelegate(`Found ${count} files to hash.`) return result.digest('hex') } else { - core.debug(`No matches found for glob`) + writeDelegate(`No matches found for glob`) return '' } }