diff --git a/.gitignore b/.gitignore index 6982a78d8..acb08fb8d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __tests__/runner/* # but its recommended not to check these in https://github.com/actions/toolkit/blob/master/docs/action-versioning.md#recommendations node_modules coverage +.env \ No newline at end of file diff --git a/action.yml b/action.yml index ee219b7b5..6e77d6ae4 100644 --- a/action.yml +++ b/action.yml @@ -1,28 +1,28 @@ # https://help.github.com/en/articles/metadata-syntax-for-github-actions -name: "GH Release" -description: "Github Action for creating Github Releases" -author: "softprops" +name: 'GH Release' +description: 'Github Action for creating Github Releases' +author: 'softprops' inputs: body: - description: "Note-worthy description of changes in release" + description: 'Note-worthy description of changes in release' required: false body_path: - description: "Path to load note-worthy description of changes in release from" + description: 'Path to load note-worthy description of changes in release from' required: false name: - description: "Gives the release a custom name. Defaults to tag name" + description: 'Gives the release a custom name. Defaults to tag name' required: false tag_name: - description: "Gives a tag name. Defaults to github.ref_name. refs/tags/ values are normalized to ." + description: 'Gives a tag name. Defaults to github.ref_name. refs/tags/ values are normalized to .' required: false draft: - description: "Keeps the release as a draft. Defaults to false. When reusing an existing draft release, set this to true to keep it draft; omit it to publish after upload. On immutable-release repositories, use this for prereleases that upload assets and publish the draft later." + description: 'Keeps the release as a draft. Defaults to false. When reusing an existing draft release, set this to true to keep it draft; omit it to publish after upload. On immutable-release repositories, use this for prereleases that upload assets and publish the draft later.' required: false prerelease: - description: "Identify the release as a prerelease. Defaults to false" + description: 'Identify the release as a prerelease. Defaults to false' required: false preserve_order: - description: "Upload artifacts sequentially in the provided order. This does not control the final display order GitHub uses for release assets." + description: 'Upload artifacts sequentially in the provided order. This does not control the final display order GitHub uses for release assets.' required: false files: description: "Newline-delimited list of path globs for asset files to upload. Escape glob metacharacters when matching literal filenames that contain them. `~/...` expands to the runner home directory. On Windows, both \\ and / path separators are accepted. GitHub may normalize raw asset filenames that contain special characters; the action restores the asset label when possible, but the final download name remains GitHub-controlled." @@ -31,52 +31,55 @@ inputs: description: "Base directory to resolve 'files' globs against. Defaults to the workspace root used by the action step." required: false overwrite_files: - description: "Overwrite existing files with the same name. Defaults to true" + description: 'Overwrite existing files with the same name. Defaults to true' required: false default: 'true' fail_on_unmatched_files: - description: "Fails if any of the `files` globs match nothing. Defaults to false" + description: 'Fails if any of the `files` globs match nothing. Defaults to false' required: false repository: - description: "Repository to make releases against, in / format" + description: 'Repository to make releases against, in / format' required: false token: - description: "Authorized GitHub token or PAT. Defaults to github.token when omitted. A non-empty explicit token overrides GITHUB_TOKEN. Passing an empty string treats the token as unset." + description: 'Authorized GitHub token or PAT. Defaults to github.token when omitted. A non-empty explicit token overrides GITHUB_TOKEN. Passing an empty string treats the token as unset.' required: false default: ${{ github.token }} target_commitish: - description: "Commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. When creating a new tag for an older commit, `github.token` may not have permission to create the ref; use a PAT or another token with sufficient contents permissions if you hit 403 `Resource not accessible by integration`." + description: 'Commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. When creating a new tag for an older commit, `github.token` may not have permission to create the ref; use a PAT or another token with sufficient contents permissions if you hit 403 `Resource not accessible by integration`.' required: false discussion_category_name: - description: "If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. If there is already a discussion linked to the release, this parameter is ignored." + description: 'If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. If there is already a discussion linked to the release, this parameter is ignored.' required: false generate_release_notes: - description: "Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes." + description: 'Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes.' required: false previous_tag: description: "Optional. When generate_release_notes is enabled, use this tag as GitHub's previous_tag_name comparison base. If omitted, GitHub chooses the comparison base automatically." required: false - default: "" + default: '' append_body: - description: "Append to existing body instead of overwriting it. Default is false." + description: 'Append to existing body instead of overwriting it. Default is false.' required: false make_latest: - description: "Specifies whether this release should be set as the latest release for the repository. Drafts and prereleases cannot be set as latest. Can be `true`, `false`, or `legacy`. Uses GitHub api default if not provided" + description: 'Specifies whether this release should be set as the latest release for the repository. Drafts and prereleases cannot be set as latest. Can be `true`, `false`, or `legacy`. Uses GitHub api default if not provided' + required: false + latest: + description: 'Update the latest release. Default is false.' required: false env: - GITHUB_TOKEN: "As provided by Github Actions" + GITHUB_TOKEN: 'As provided by Github Actions' outputs: url: - description: "URL to the Release HTML Page" + description: 'URL to the Release HTML Page' id: - description: "Release ID" + description: 'Release ID' upload_url: - description: "URL for uploading assets to the release" + description: 'URL for uploading assets to the release' assets: - description: "JSON array containing information about each uploaded asset, in the format given [here](https://docs.github.com/en/rest/reference/repos#upload-a-release-asset--code-samples) (minus the `uploader` field)" + description: 'JSON array containing information about each uploaded asset, in the format given [here](https://docs.github.com/en/rest/reference/repos#upload-a-release-asset--code-samples) (minus the `uploader` field)' runs: - using: "node24" - main: "dist/index.js" + using: 'node24' + main: 'dist/index.js' branding: - color: "green" - icon: "package" + color: 'green' + icon: 'package' diff --git a/src/github.ts b/src/github.ts index df52433d2..94e7d4d75 100644 --- a/src/github.ts +++ b/src/github.ts @@ -55,6 +55,8 @@ type ReleaseMutationParams = { }; export interface Releaser { + getLatestRelease(params: { owner: string; repo: string }): Promise<{ data: Release }>; + getReleaseByTag(params: { owner: string; repo: string; tag: string }): Promise<{ data: Release }>; createRelease(params: ReleaseMutationParams): Promise<{ data: Release }>; @@ -109,6 +111,10 @@ export class GitHubReleaser implements Releaser { this.github = github; } + getLatestRelease(params: { owner: string; repo: string }): Promise<{ data: Release }> { + return this.github.rest.repos.getLatestRelease(params); + } + getReleaseByTag(params: { owner: string; repo: string; @@ -506,7 +512,7 @@ export const release = async ( } const [owner, repo] = config.github_repository.split('/'); - const tag = + let tag = normalizeTagName(config.input_tag_name) || (isTag(config.github_ref) ? config.github_ref.replace('refs/tags/', '') : ''); @@ -518,7 +524,15 @@ export const release = async ( console.log(`📝 Generating release notes using previous tag ${previous_tag_name}`); } try { - const _release: Release | undefined = await findTagFromReleases(releaser, owner, repo, tag); + let _release: Release | undefined; + if (config.input_latest) { + _release = await findLatestRelease(releaser, owner, repo); + if (_release !== undefined) { + tag = _release.tag_name; + } + } else { + _release = await findTagFromReleases(releaser, owner, repo, tag); + } if (_release === undefined) { return await createRelease( @@ -716,6 +730,31 @@ export const listReleaseAssets = async ( } }; +/** + * Find the latest release. + * + * @param releaser - The GitHub API wrapper for release operations + * @param owner - The owner of the repository + * @param repo - The name of the repository + * @returns The release with the given tag name, or undefined if no release with that tag name is found + */ +export async function findLatestRelease( + releaser: Releaser, + owner: string, + repo: string, +): Promise { + try { + const { data: release } = await releaser.getLatestRelease({ owner, repo }); + return release; + } catch (error) { + // Release not found (404) or other error - return undefined to allow creation + if (error.status === 404) { + return undefined; + } + // Re-throw unexpected errors + throw error; + } +} /** * Finds a release by tag name. * diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..0931f70d0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +/** + * This file is the entrypoint for the action + */ +import { run } from './main'; + +// It calls the actual logic of the action +run(); diff --git a/src/main.ts b/src/main.ts index 627171773..314a10269 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,10 +5,15 @@ import { isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util' import { env } from 'process'; -async function run() { +export async function run() { try { const config = parseConfig(env); - if (!config.input_tag_name && !isTag(config.github_ref) && !config.input_draft) { + if ( + !config.input_latest && + !config.input_tag_name && + !isTag(config.github_ref) && + !config.input_draft + ) { throw new Error(`⚠️ GitHub Releases requires a tag`); } if (config.input_files) { @@ -110,5 +115,3 @@ async function run() { setFailed(error.message); } } - -run(); diff --git a/src/util.ts b/src/util.ts index 49a81d9ba..6b94bb996 100644 --- a/src/util.ts +++ b/src/util.ts @@ -26,6 +26,7 @@ export interface Config { input_previous_tag?: string; input_append_body?: boolean; input_make_latest: 'true' | 'false' | 'legacy' | undefined; + input_latest?: boolean; } export const uploadUrl = (url: string): string => { @@ -118,6 +119,7 @@ export const parseConfig = (env: Env): Config => { input_previous_tag: env.INPUT_PREVIOUS_TAG?.trim() || undefined, input_append_body: env.INPUT_APPEND_BODY == 'true', input_make_latest: parseMakeLatest(env.INPUT_MAKE_LATEST), + input_latest: env.INPUT_LATEST == 'true', }; };