From f6250f34da1a9b67832352b74ff42f2e38dfbdff Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Tue, 16 Apr 2024 10:36:21 +0100 Subject: [PATCH] Initial import of npm-package-versioner code --- .github/workflows/ci.yml | 4 - CODEOWNERS | 3 - LICENSE | 4 +- README.md | 62 ++----------- __tests__/main.test.ts | 89 ------------------- __tests__/version.test.ts | 182 ++++++++++++++++++++++++++++++++++++++ __tests__/wait.test.ts | 25 ------ action.yml | 25 ++---- package.json | 16 ++-- script/release | 59 ------------ src/main.ts | 35 +++++--- src/version.ts | 94 ++++++++++++++++++++ src/wait.ts | 14 --- 13 files changed, 321 insertions(+), 291 deletions(-) delete mode 100644 CODEOWNERS delete mode 100644 __tests__/main.test.ts create mode 100644 __tests__/version.test.ts delete mode 100644 __tests__/wait.test.ts delete mode 100755 script/release create mode 100644 src/version.ts delete mode 100644 src/wait.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6d2861..9b3cdf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,3 @@ jobs: uses: ./ with: milliseconds: 2000 - - - name: Print Output - id: output - run: echo "${{ steps.test-action.outputs.time }}" diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index 2e08bd2..0000000 --- a/CODEOWNERS +++ /dev/null @@ -1,3 +0,0 @@ -# Repository CODEOWNERS - -* @actions/actions-oss-maintainers diff --git a/LICENSE b/LICENSE index 5f9e342..0e1af1b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright GitHub +Copyright Micro:bit Educational Foundation, GitHub Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ 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. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 3e1c006..85e7a3c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Create a GitHub Action Using TypeScript +# NPM package versioner GitHub action [![GitHub Super-Linter](https://github.com/actions/typescript-action/actions/workflows/linter.yml/badge.svg)](https://github.com/super-linter/super-linter) ![CI](https://github.com/actions/typescript-action/actions/workflows/ci.yml/badge.svg) @@ -6,32 +6,18 @@ [![CodeQL](https://github.com/actions/typescript-action/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/actions/typescript-action/actions/workflows/codeql-analysis.yml) [![Coverage](./badges/coverage.svg)](./badges/coverage.svg) -Use this template to bootstrap the creation of a TypeScript action. :rocket: +Manages the `package.json` version field for GitHub action. -This template includes compilation support, tests, a validation workflow, -publishing, and versioning guidance. +The action will write the new version number to package.json. -If you are new, there's also a simpler introduction in the -[Hello world JavaScript action repository](https://github.com/actions/hello-world-javascript-action). +Usage: -## Create Your Own Action - -To create your own action, you can use this repository as a template! Just -follow the below instructions: - -1. Click the **Use this template** button at the top of the repository -1. Select **Create a new repository** -1. Select an owner and name for your new repository -1. Click **Create repository** -1. Clone your new repository - -> [!IMPORTANT] -> -> Make sure to remove or update the [`CODEOWNERS`](./CODEOWNERS) file! For -> details on how to use this file, see -> [About code owners](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners). +``` +steps: + - uses: microbit-foundation/npm-package-versioner-action@v1 +``` -## Initial Setup +## Development documentation from template repo After you've cloned the repository to your local machine or codespace, you'll need to perform some initial setup steps before you can develop your action. @@ -72,15 +58,6 @@ need to perform some initial setup steps before you can develop your action. ... ``` -## Update the Action Metadata - -The [`action.yml`](action.yml) file defines metadata about your action, such as -input(s) and output(s). For details about this file, see -[Metadata syntax for GitHub Actions](https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions). - -When you copy this repository, update `action.yml` with the name, description, -inputs, and outputs for your action. - ## Update the Action Code The [`src/`](./src/) directory is the heart of your action! This contains the @@ -206,24 +183,3 @@ steps: id: output run: echo "${{ steps.test-action.outputs.time }}" ``` - -## Publishing a New Release - -This project includes a helper script, [`script/release`](./script/release) -designed to streamline the process of tagging and pushing new releases for -GitHub Actions. - -GitHub Actions allows users to select a specific version of the action to use, -based on release tags. This script simplifies this process by performing the -following steps: - -1. **Retrieving the latest release tag:** The script starts by fetching the most - recent release tag by looking at the local data available in your repository. -1. **Prompting for a new release tag:** The user is then prompted to enter a new - release tag. To assist with this, the script displays the latest release tag - and provides a regular expression to validate the format of the new tag. -1. **Tagging the new release:** Once a valid new tag is entered, the script tags - the new release. -1. **Pushing the new tag to the remote:** Finally, the script pushes the new tag - to the remote repository. From here, you will need to create a new release in - GitHub and users can easily reference the new tag in their workflows. diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts deleted file mode 100644 index ce373fb..0000000 --- a/__tests__/main.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Unit tests for the action's main functionality, src/main.ts - * - * These should be run as if the action was called from a workflow. - * Specifically, the inputs listed in `action.yml` should be set as environment - * variables following the pattern `INPUT_`. - */ - -import * as core from '@actions/core' -import * as main from '../src/main' - -// Mock the action's main function -const runMock = jest.spyOn(main, 'run') - -// Other utilities -const timeRegex = /^\d{2}:\d{2}:\d{2}/ - -// Mock the GitHub Actions core library -let debugMock: jest.SpiedFunction -let errorMock: jest.SpiedFunction -let getInputMock: jest.SpiedFunction -let setFailedMock: jest.SpiedFunction -let setOutputMock: jest.SpiedFunction - -describe('action', () => { - beforeEach(() => { - jest.clearAllMocks() - - debugMock = jest.spyOn(core, 'debug').mockImplementation() - errorMock = jest.spyOn(core, 'error').mockImplementation() - getInputMock = jest.spyOn(core, 'getInput').mockImplementation() - setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation() - setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation() - }) - - it('sets the time output', async () => { - // Set the action's inputs as return values from core.getInput() - getInputMock.mockImplementation(name => { - switch (name) { - case 'milliseconds': - return '500' - default: - return '' - } - }) - - await main.run() - expect(runMock).toHaveReturned() - - // Verify that all of the core library functions were called correctly - expect(debugMock).toHaveBeenNthCalledWith(1, 'Waiting 500 milliseconds ...') - expect(debugMock).toHaveBeenNthCalledWith( - 2, - expect.stringMatching(timeRegex) - ) - expect(debugMock).toHaveBeenNthCalledWith( - 3, - expect.stringMatching(timeRegex) - ) - expect(setOutputMock).toHaveBeenNthCalledWith( - 1, - 'time', - expect.stringMatching(timeRegex) - ) - expect(errorMock).not.toHaveBeenCalled() - }) - - it('sets a failed status', async () => { - // Set the action's inputs as return values from core.getInput() - getInputMock.mockImplementation(name => { - switch (name) { - case 'milliseconds': - return 'this is not a number' - default: - return '' - } - }) - - await main.run() - expect(runMock).toHaveReturned() - - // Verify that all of the core library functions were called correctly - expect(setFailedMock).toHaveBeenNthCalledWith( - 1, - 'milliseconds not a number' - ) - expect(errorMock).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/version.test.ts b/__tests__/version.test.ts new file mode 100644 index 0000000..8807356 --- /dev/null +++ b/__tests__/version.test.ts @@ -0,0 +1,182 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import assert from 'node:assert' +import { contextFromEnvironment, generateVersion } from '../src/version' + +describe(`generateVersion`, () => { + const defaultContext = { + ci: true, + buildNumber: 34, + tag: undefined, + branch: undefined + } + + it(`should use local suffix when not in CI`, () => { + const context = { + ...defaultContext, + ci: false + } + assert.deepEqual(generateVersion('1.2.3-foo', context), { + version: '1.2.3-local' + }) + }) + + it(`should use tag when tag specified`, () => { + const context = { + ...defaultContext, + tag: '3.2.1' + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + version: '3.2.1' + }) + }) + + it(`should strip the v prefix from tags`, () => { + const context = { + ...defaultContext, + tag: 'v3.2.1' + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + version: '3.2.1' + }) + }) + + it(`work for v1.1.1`, () => { + const context = { + ...defaultContext, + tag: 'v1.1.1' + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + version: '1.1.1' + }) + }) + + it(`should error for non-semver tag`, () => { + const context = { + ...defaultContext, + tag: 'wibble' + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + error: 'Invalid semver tag: wibble' + }) + }) + + it(`should use the build number for branches`, () => { + const context = { + ...defaultContext, + branch: 'wobble' + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + version: '1.0.0-wobble.34' + }) + }) + + // Assumption is that you only use one of these for versioned builds. + for (const usesDev of ['master', 'main', 'develop']) { + it(`should use dev for ${usesDev}`, () => { + const context = { + ...defaultContext, + branch: usesDev + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + version: '1.0.0-dev.34' + }) + }) + } + + it(`should error if no branch or tag`, () => { + assert.deepEqual(generateVersion('1.0.0-local', defaultContext), { + error: 'Could not determine a version. CI environment invalid?' + }) + }) + + it(`should sanitise branch names`, () => { + const context = { + ...defaultContext, + branch: 'feature/£-foo-bar\\blort-1234.99' + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + version: '1.0.0-feature.foo.bar.blort.1234.99.34' + }) + }) + + it(`should use dot separated components as per semver`, () => { + const context = { + ...defaultContext, + branch: 'feature/my-fave-feature' + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + version: '1.0.0-feature.my.fave.feature.34' + }) + }) + + it(`treat underscores as separators`, () => { + const context = { + ...defaultContext, + branch: 'foo_bar' + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + version: '1.0.0-foo.bar.34' + }) + }) + + it(`should use a placeholder if the branch is sanitized away`, () => { + const context = { + ...defaultContext, + branch: '---' + } + assert.deepEqual(generateVersion('1.0.0-local', context), { + version: '1.0.0-branch.34' + }) + }) +}) + +describe('contextFromEnvironment', () => { + it('works for GitHub actions branch case', () => { + assert.deepEqual( + contextFromEnvironment({ + GITHUB_ACTION: 'lala', + CI: 'true', + GITHUB_REF: 'refs/heads/asdf', + GITHUB_RUN_NUMBER: '12' + }), + { + branch: 'asdf', + buildNumber: 12, + ci: true, + tag: undefined + } + ) + }) + it('works for GitHub actions tag case', () => { + assert.deepEqual( + contextFromEnvironment({ + GITHUB_ACTION: 'lala', + CI: 'true', + GITHUB_REF: 'refs/tags/asdf', + GITHUB_RUN_NUMBER: '12' + }), + { + branch: undefined, + buildNumber: 12, + ci: true, + tag: 'asdf' + } + ) + }) + it('works for GitHub actions tag case numeric', () => { + assert.deepEqual( + contextFromEnvironment({ + GITHUB_ACTION: 'lala', + CI: 'true', + GITHUB_REF: 'refs/tags/v1.1.1', + GITHUB_RUN_NUMBER: '12' + }), + { + branch: undefined, + buildNumber: 12, + ci: true, + tag: 'v1.1.1' + } + ) + }) +}) diff --git a/__tests__/wait.test.ts b/__tests__/wait.test.ts deleted file mode 100644 index 1336aaa..0000000 --- a/__tests__/wait.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Unit tests for src/wait.ts - */ - -import { wait } from '../src/wait' -import { expect } from '@jest/globals' - -describe('wait.ts', () => { - it('throws an invalid number', async () => { - const input = parseInt('foo', 10) - expect(isNaN(input)).toBe(true) - - await expect(wait(input)).rejects.toThrow('milliseconds not a number') - }) - - it('waits with a valid number', async () => { - const start = new Date() - await wait(500) - const end = new Date() - - const delta = Math.abs(end.getTime() - start.getTime()) - - expect(delta).toBeGreaterThan(450) - }) -}) diff --git a/action.yml b/action.yml index 101186a..33dc107 100644 --- a/action.yml +++ b/action.yml @@ -1,23 +1,8 @@ -name: 'The name of your action here' -description: 'Provide a description here' -author: 'Your name or organization here' - -# Add your action's branding here. This will appear on the GitHub Marketplace. -branding: - icon: 'heart' - color: 'red' - -# Define your inputs here. -inputs: - milliseconds: - description: 'Your input description here' - required: true - default: '1000' - -# Define your outputs here. -outputs: - time: - description: 'Your output description here' +name: 'Set NPM package version from the GitHub actions environment' +description: + 'Sets a semver package version based on the existing version and the + branch/tag/build number' +author: 'Micro:bit Educational Foundation' runs: using: node20 diff --git a/package.json b/package.json index c1c1528..5d08d0c 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,21 @@ { - "name": "typescript-action", - "description": "GitHub Actions TypeScript template", - "version": "0.0.0", + "name": "@microbit/npm-package-versioner-action", + "description": "GitHub action to manage NPM package versions", + "version": "0.0.0-local", "author": "", "private": true, - "homepage": "https://github.com/actions/typescript-action", "repository": { "type": "git", - "url": "git+https://github.com/actions/typescript-action.git" + "url": "git+https://github.com/microbit-foundation/npm-package-versioner-action.git" }, "bugs": { - "url": "https://github.com/actions/typescript-action/issues" + "url": "https://github.com/microbit-foundation/npm-package-versioner/issues" }, "keywords": [ "actions", "node", - "setup" + "version", + "npm" ], "exports": { ".": "./dist/index.js" @@ -87,4 +87,4 @@ "ts-jest": "^29.1.2", "typescript": "^5.4.5" } -} +} \ No newline at end of file diff --git a/script/release b/script/release deleted file mode 100755 index 1ae8d07..0000000 --- a/script/release +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -# About: -# -# This is a helper script to tag and push a new release. GitHub Actions use -# release tags to allow users to select a specific version of the action to use. -# -# See: https://github.com/actions/typescript-action#publishing-a-new-release -# -# This script will do the following: -# -# 1. Get the latest release tag -# 2. Prompt the user for a new release tag -# 3. Tag the new release -# 4. Push the new tag to the remote -# -# Usage: -# -# script/release - -# Terminal colors -OFF='\033[0m' -RED='\033[0;31m' -GREEN='\033[0;32m' -BLUE='\033[0;34m' - -# Get the latest release tag -latest_tag=$(git describe --tags "$(git rev-list --tags --max-count=1)") - -if [[ -z "$latest_tag" ]]; then - # There are no existing release tags - echo -e "No tags found (yet) - Continue to create and push your first tag" - latest_tag="[unknown]" -fi - -# Display the latest release tag -echo -e "The latest release tag is: ${BLUE}${latest_tag}${OFF}" - -# Prompt the user for the new release tag -read -r -p 'Enter a new release tag (vX.X.X format): ' new_tag - -# Validate the new release tag -tag_regex='v[0-9]+\.[0-9]+\.[0-9]+$' -if echo "$new_tag" | grep -q -E "$tag_regex"; then - echo -e "Tag: ${BLUE}$new_tag${OFF} is valid" -else - # Release tag is not `vX.X.X` format - echo -e "Tag: ${BLUE}$new_tag${OFF} is ${RED}not valid${OFF} (must be in vX.X.X format)" - exit 1 -fi - -# Tag the new release -git tag -a "$new_tag" -m "$new_tag Release" -echo -e "${GREEN}Tagged: $new_tag${OFF}" - -# Push the new tag to the remote -git push --tags -echo -e "${GREEN}Release tag pushed to remote${OFF}" -echo -e "${GREEN}Done!${OFF}" diff --git a/src/main.ts b/src/main.ts index c804f90..0b54cf1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import * as core from '@actions/core' -import { wait } from './wait' +import fs from 'node:fs' +import { contextFromEnvironment, generateVersion } from './version' /** * The main function for the action. @@ -7,20 +8,26 @@ import { wait } from './wait' */ export async function run(): Promise { try { - const ms: string = core.getInput('milliseconds') - - // Debug logs are only output if the `ACTIONS_STEP_DEBUG` secret is true - core.debug(`Waiting ${ms} milliseconds ...`) - - // Log the current timestamp, wait, then log the new timestamp - core.debug(new Date().toTimeString()) - await wait(parseInt(ms, 10)) - core.debug(new Date().toTimeString()) - - // Set outputs for other workflow steps to use - core.setOutput('time', new Date().toTimeString()) + const packageJsonPath = 'package.json' + const packageJson = JSON.parse( + fs.readFileSync(packageJsonPath, { encoding: 'utf-8' }) + ) as { version: string } + const context = contextFromEnvironment(process.env) + const { error, version } = generateVersion(packageJson.version, context) + if (error || !version) { + core.setFailed(error ?? 'No version generated') + } else { + console.log(`Updating package version to ${version}`) + packageJson.version = version + fs.writeFileSync( + packageJsonPath, + JSON.stringify(packageJson, undefined, 2), + { + encoding: 'utf-8' + } + ) + } } catch (error) { - // Fail the workflow run if an error occurs if (error instanceof Error) core.setFailed(error.message) } } diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..db2ccaa --- /dev/null +++ b/src/version.ts @@ -0,0 +1,94 @@ +import { SemVer, valid as isValidSemVer } from 'semver' + +interface Context { + ci: boolean + branch: string | undefined + tag: string | undefined + buildNumber: number | undefined +} + +interface VersionUpdateResult { + version?: string + error?: string +} + +const parseIntOrThrow = (s: string) => { + const n = parseInt(s, 10) + if (isNaN(n)) { + throw new Error(`Could not parse integer '${s}'`) + } + return n +} + +const github: (env: typeof process.env) => Context = env => { + const ref = env.GITHUB_REF + let branch: string | undefined + let tag: string | undefined + if (ref) { + if (ref.startsWith('refs/tags/')) { + tag = ref.slice('refs/tags/'.length) + } else if (ref.startsWith('refs/heads/')) { + branch = ref.slice('refs/heads/'.length) + } + } + return { + ci: !!env.CI, + branch, + tag, + buildNumber: + typeof env.GITHUB_RUN_NUMBER === 'undefined' + ? undefined + : parseIntOrThrow(env.GITHUB_RUN_NUMBER) + } +} + +export const contextFromEnvironment = (env: typeof process.env): Context => { + return github(env) +} + +const sanitizeBranchName = (branch: string) => { + if (branch === 'main' || branch === 'master' || branch === 'develop') { + return 'dev' + } + + return ( + branch + .split(/[\\/\-._]/) + .map(x => x.replace(/[^a-zA-Z0-9]/, '').replace(/^0+/, '')) + .filter(x => x.length > 0) + .join('.') || 'branch' + ) +} + +/** + * + * @param inPackageJson The version number as specified in package.json. + * @param context The build context from CI. + */ +export const generateVersion: ( + inPackageJson: string, + context: Context +) => VersionUpdateResult = (inPackageJson, context) => { + const version = new SemVer(inPackageJson) + if (!context.ci) { + version.prerelease = ['local'] + return { version: version.format() } + } + if (context.tag) { + const tag = + context.tag.charAt(0) === 'v' ? context.tag.substring(1) : context.tag + if (!isValidSemVer(tag)) { + return { error: 'Invalid semver tag: ' + tag } + } + return { version: tag } + } + const { branch } = context + if (branch && typeof context.buildNumber !== 'undefined') { + version.prerelease = [ + sanitizeBranchName(branch), + context.buildNumber.toString(10) + ] + return { version: version.format() } + } + return { error: 'Could not determine a version. CI environment invalid?' } +} diff --git a/src/wait.ts b/src/wait.ts deleted file mode 100644 index 0ddf692..0000000 --- a/src/wait.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Wait for a number of milliseconds. - * @param milliseconds The number of milliseconds to wait. - * @returns {Promise} Resolves with 'done!' after the wait is over. - */ -export async function wait(milliseconds: number): Promise { - return new Promise(resolve => { - if (isNaN(milliseconds)) { - throw new Error('milliseconds not a number') - } - - setTimeout(() => resolve('done!'), milliseconds) - }) -}