diff --git a/action.yml b/action.yml index f0c4f93..ec2dad1 100644 --- a/action.yml +++ b/action.yml @@ -6,42 +6,52 @@ inputs: branches: description: 'The your branches that you want to purge cache existing in jsDelivr' required: false - default: 'master main' - - delay: - description: 'A time that delay between GitHub RAW file service and actual git operation' - required: false - default: '2:0:0' + default: '' runs: using: 'composite' steps: - name: Set up NodeJS LTS - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 'lts/*' - name: Install npm packages run: | - sudo npm i -g npm - sudo npm i -g typescript + sudo npm update -g npm + sudo npm update -g typescript npm i shell: bash working-directory: ${{ github.action_path }} - - name: Compile Typescript - run: tsc - shell: bash - working-directory: ${{ github.action_path }} - - name: Create env file + - name: Check if size of the repo exceeds the runner's hardware capacity + id: check_size + env: + GITHUB_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + CI_WORKSPACE_PATH: ${{ github.workspace }} run: | - touch .env - echo GITHUB_REPO=${{ github.repository }} >> .env - echo GITHUB_TOKEN=${{ github.token }} >> .env - echo GITHUB_WORKFLOW_REF=${{ github.workflow_ref }} >> .env - echo INPUT_BRANCHES=${{ inputs.branches }} >> .env - echo INPUT_DELAY=${{ inputs.delay }} >> .env + npm run calc-repo-size -- --gh-token "$GITHUB_TOKEN" \ + --repo "$REPO" \ + --ci-workspace-path "$CI_WORKSPACE_PATH" shell: bash working-directory: ${{ github.action_path }} - - name: Run compiled scripts - run: node main.js + - name: Clone repo into github.workspace + uses: actions/checkout@v4 + if: ${{ steps.check_size.outputs.should_api != 'true' }} + working-directory: ${{ github.workspace }} + - name: Run program + env: + GITHUB_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BRANCHE: ${{ inputs.branches }} + WORKFLOWREF: ${{ github.workflow_ref }} + CI_WORKSPACE_PATH: ${{ github.workspace }} + CI_ACTION_PATH: ${{ github.action_path }} + SHOULD_USE_API: ${{ steps.check_size.outputs.should_use_api }} + run: | + npm run ci -- --gh-token "$GITHUB_TOKEN" --repo "$REPO" \ + --workflow-ref "$WORKFLOWREF" --branch "$BRANCHE" \ + --ci-workspace-path "$CI_WORKSPACE_PATH" \ + --ci-action-path "$CI_ACTION_PATH" \ + --should-use-api "$SHOULD_USE_API" shell: bash working-directory: ${{ github.action_path }} \ No newline at end of file diff --git a/calc-repo-size/index.ts b/calc-repo-size/index.ts new file mode 100644 index 0000000..846a6fc --- /dev/null +++ b/calc-repo-size/index.ts @@ -0,0 +1,39 @@ +import * as GitHub from '@octokit/rest' +import * as Actions from '@actions/core' +import * as Commander from 'commander' +import checkDiskSpace from 'check-disk-space' + +const Program = new Commander.Command() + +Program.option('--debug', 'output extra debugging', false) + .option('--gh-token ', 'GitHub token', '') + .option('--repo ', 'A GitHub repository. eg: owner/repo', '') + .option('--ci-workspace-path ', 'A path to the CI workspace.', '') + +Program.parse() + +type ProgramOptionsType = { + // eslint-disable-next-line @typescript-eslint/naming-convention + debug: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention + ghToken: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + repo: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + ciWorkspacePath: string; +} +const ProgramOptions: ProgramOptionsType = Program.opts() + +const GitHubInstance = new GitHub.Octokit({auth: ProgramOptions.ghToken}) +const RepoSize = GitHubInstance.repos.get({owner: ProgramOptions.repo.split('/')[0], repo: ProgramOptions.repo.split('/')[1]}) + .then(Response => Response.data.size) +const DiskFreeSize = checkDiskSpace(ProgramOptions.ciWorkspacePath).then(DiskInfo => DiskInfo.free) + +await Promise.all([RepoSize, DiskFreeSize]).then(([RepoSizeVaule, DiskFreeSizeVaule]) => { + Actions.info(`calc-repo-size: RepoSize: ${RepoSizeVaule}; DiskFreeSize: ${DiskFreeSizeVaule}`) + if (RepoSizeVaule * 1000 < DiskFreeSizeVaule) { + Actions.setOutput('should_use_api', 'false') + } else { + Actions.setOutput('should_use_api', 'true') + } +}) diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..4a10758 --- /dev/null +++ b/index.ts @@ -0,0 +1,46 @@ +import * as Commander from 'commander' +import type * as Types from './sources/types' +import {ExportArgs, IsDebug} from './sources/debug' +import {ReplaceStringWithBooleanInObject} from './sources/utility' +import {GetLatestWorkflowTime} from './sources/actions' +import {ListBranches} from './sources/branches' +import {GetChangedFilesFromSHAToHead, GetCommitSHAFromLatestWorkflowTime} from './sources/commits' +import {PurgeRequestManager} from './sources/requests' + +const Program = new Commander.Command() + +// Set options. +Program.option('--debug', 'output extra debugging', false) + .option('--gh-token ', 'GitHub token', '') + .option('--repo ', 'A GitHub repository. eg: owner/repo', '') + .option('--workflow-ref ', 'A GitHub workflow ref. eg: refs/heads/master', '') + .option('--branch ', 'A GitHub branch. eg: master', '') + .option('--ci-workspace-path ', 'A path to the CI workspace.', '') + .option('--ci-action-path ', 'A path to the CI action.', '') + .option('--should-use-api ', 'Should use GitHub API?', 'false') + +// Initialize Input of the options and export them. +Program.parse() + +// Declare the options and print them if the debugging mode is enabled. +const ProgramRawOptions: Types.ProgramOptionsRawType = Program.opts() +if (IsDebug(ProgramRawOptions)) { + ExportArgs(ProgramRawOptions) +} + +// Redefine with boolean. +const ProgramOptions = ReplaceStringWithBooleanInObject(ProgramRawOptions) as Types.ProgramOptionsType + +// Workflow +const LatestWorkflowRunTime = await GetLatestWorkflowTime(ProgramOptions).then(LatestWorkflowRunTime => LatestWorkflowRunTime) +const Branches = await ListBranches(ProgramOptions).then(Branches => Branches) +const PurgeRequest = new PurgeRequestManager(ProgramOptions) +for (const Branch of Branches) { + // eslint-disable-next-line no-await-in-loop + const CommitSHA = await GetCommitSHAFromLatestWorkflowTime(ProgramOptions, LatestWorkflowRunTime, Branch).then(CommitSHA => CommitSHA) + // eslint-disable-next-line no-await-in-loop + const ChangedFiles = await GetChangedFilesFromSHAToHead(ProgramOptions, CommitSHA, Branch).then(ChangedFiles => ChangedFiles) + PurgeRequest.AddURLs(ChangedFiles, Branch) +} + +PurgeRequest.Start() diff --git a/main.ts b/main.ts deleted file mode 100644 index 5bc115d..0000000 --- a/main.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as Actions from '@actions/core' -import * as GitHub from '@octokit/rest' -import * as Threads from 'worker_threads' -import * as Dotenv from 'dotenv' -import { DateTime } from 'luxon' - -Dotenv.config() -const Octokit = new GitHub.Octokit({ auth: process.env['GITHUB_TOKEN'] }) -const [RepoOwner, RepoName] = process.env['GITHUB_REPO'].split('/') -const KnownBranches = await Octokit.rest.repos.listBranches({ owner: RepoOwner, repo: RepoName, per_page: Number.MAX_SAFE_INTEGER }) - .then(Result => Result.data.map(Data => Data.name)) -let Branches = process.env['INPUT_BRANCHES'].split(' ') -let BrancheThreads:Threads.Worker[] = [] - -// Check if an user selects all branches and selected branches are valid. -if (Branches.length === 1 && Branches[0] === '**') { - Branches = KnownBranches -} else { - Branches.forEach((element) => { - if (!(KnownBranches.includes(element))) { - Branches = Branches.filter(Branch => Branch === element ) - Actions.warning(`The ${element} branch does not exist.`) - } - }) -} - -// Check delay input -if (DateTime.fromFormat(process.env['INPUT_DELAY'], 'H:m:s').isValid) { - const Delay = DateTime.fromFormat(process.env['INPUT_DELAY'], 'H:m:s') - if (Delay.hour > 12 || (Delay.hour === 12 && (Delay.minute > 0 || Delay.second > 0))) { - Actions.setFailed('The delay input must be 12 hours or shorter.') - } else if (Delay.hour === 0 && ((Delay.minute === 30 && Delay.second > 0) || Delay.minute < 30)) { - Actions.setFailed('The delay input must be 30 minutes or longer.') - } -} else { - Actions.setFailed(`The delay input is invalid format: - ${process.env['INPUT_DELAY']} - `) -} - -Actions.info(`The following branches will be processed: -${Branches.join('\n - ').replace(/^/, ' - ')} -`) - -Branches.forEach((Branche, Index) => { - BrancheThreads.push(new Threads.Worker('./threads.js')) - BrancheThreads[Index].postMessage(Branche) - BrancheThreads[Index].on('exit', (ExitCode) => { - BrancheThreads = BrancheThreads.filter((element) => element === BrancheThreads[Index]) - if (!(BrancheThreads.length)) process.exit(0) - }) -}) \ No newline at end of file diff --git a/sources/actions.ts b/sources/actions.ts new file mode 100644 index 0000000..5abaf81 --- /dev/null +++ b/sources/actions.ts @@ -0,0 +1,27 @@ +import * as GitHub from '@octokit/rest' +import {DateTime} from 'luxon' +import type {ProgramOptionsType} from './types' + +/** + * @name GetLatestWorkflowTime + * @description Get the latest workflow time. + * @param {ProgramOptionsType} ProgramOptions The program options. + * @returns {Promise} The latest workflow time in milliseconds. + */ +export async function GetLatestWorkflowTime(ProgramOptions: ProgramOptionsType): Promise { + const GitHubInstance = new GitHub.Octokit({auth: ProgramOptions.ghToken}) + const [RepoOwner, RepoName] = ProgramOptions.repo.split('/') + var LatestWorkflowRunTime = Number.MIN_SAFE_INTEGER + const WorkflowRuns = await GitHubInstance.actions.listWorkflowRuns({ + owner: RepoOwner, repo: RepoName, + workflow_id: /(?<=^[A-z0-9]+\/[A-z0-9]+\/\.github\/workflows\/).+\.yml(?=@refs\/)/.exec(ProgramOptions.workflowRef)[0], + }).then(WorkflowRuns => WorkflowRuns.data.workflow_runs) + for (const WorkflowRun of WorkflowRuns) { + if (WorkflowRun.status === 'completed' && WorkflowRun.conclusion === 'success' + && DateTime.fromISO(WorkflowRun.updated_at).toMillis() > LatestWorkflowRunTime) { + LatestWorkflowRunTime = DateTime.fromISO(WorkflowRun.updated_at).toMillis() + } + } + + return LatestWorkflowRunTime +} diff --git a/sources/branches.ts b/sources/branches.ts new file mode 100644 index 0000000..8bacc2f --- /dev/null +++ b/sources/branches.ts @@ -0,0 +1,48 @@ +import * as Git from 'simple-git' +import * as GitHub from '@octokit/rest' +import * as Actions from '@actions/core' +import * as Os from 'node:os' +import type * as Types from './types' +import {IsDebug} from './debug' + +function CreateGitHubInstance(ProgramOptions: Types.ProgramOptionsType): GitHub.Octokit { + const GitHubInstance = new GitHub.Octokit({auth: ProgramOptions.ghToken}) + return GitHubInstance +} + +function CreateGitInstance(BasePath: string): Git.SimpleGit { + const GitInstance = Git.simpleGit(BasePath, {maxConcurrentProcesses: Os.cpus().length}) + return GitInstance +} + +/** + * @name ListBranches + * @description List all branches that should be purged. + * @param {Types.ProgramOptions} ProgramOptions The program options. + * @returns {string[]} A list of branches. The list always contains 'latest' and the current/default branch. + */ +export async function ListBranches(ProgramOptions: Types.ProgramOptionsType): Promise { + const Branches: string[] = ['latest'] + if (ProgramOptions.shouldUseApi) { + const GitHubInstance = CreateGitHubInstance(ProgramOptions) + const [RepoOwner, RepoName] = ProgramOptions.repo.split('/') + Branches.push((await GitHubInstance.repos.get({owner: RepoOwner, repo: RepoName})).data.default_branch) + const OtherBranches = (await GitHubInstance.repos.listBranches({owner: RepoOwner, repo: RepoName}).then(Branches => Branches.data)) + .map(Item => Item.name) + OtherBranches.forEach(Item => Branches.push(ProgramOptions.branch.split(' ').find(Branch => Branch === Item))) + } + + if (!ProgramOptions.shouldUseApi) { + const GitInstance = CreateGitInstance(`${ProgramOptions.ciWorkspacePath}/${ProgramOptions.repo.split('/')[1]}`) + Branches.push(await GitInstance.branchLocal().then(Branches => Branches.current)) + // Branches[1] is always the current/default branch. + const OtherBranches = (await GitInstance.branchLocal().then(Branches => Branches.all)).filter(Branch => Branch !== Branches[1]) + OtherBranches.forEach(Item => Branches.push(ProgramOptions.branch.split(' ').find(Branch => Branch === Item))) + } + + if (IsDebug(ProgramOptions)) { + Actions.debug(`ListBranches in branches.ts called: ${JSON.stringify(Branches)}`) + } + + return Branches +} diff --git a/sources/commits.ts b/sources/commits.ts new file mode 100644 index 0000000..b3f5a15 --- /dev/null +++ b/sources/commits.ts @@ -0,0 +1,86 @@ +import * as Git from 'simple-git' +import * as GitHub from '@octokit/rest' +import * as Os from 'node:os' +import {DateTime} from 'luxon' +import type * as Types from './types' + +function CreateGitHubInstance(ProgramOptions: Types.ProgramOptionsType): GitHub.Octokit { + const GitHubInstance = new GitHub.Octokit({auth: ProgramOptions.ghToken}) + return GitHubInstance +} + +function CreateGitInstance(BasePath: string): Git.SimpleGit { + const GitInstance = Git.simpleGit(BasePath, {maxConcurrentProcesses: Os.cpus().length}) + return GitInstance +} + +/** + * @name ListCommitsFromLatestWorkflowTime + * @description List commits using GitHub Octokit or simple-git. + * @param {Types.ProgramOptionsType} ProgramOptions The program options. + * @param {number} LatestWorkflowRunTime The latest workflow time in milliseconds. + * @param {string} Branch The branch or tag name. + * @returns {Promise} SHA of the latest commit. + */ +export async function GetCommitSHAFromLatestWorkflowTime(ProgramOptions: Types.ProgramOptionsType, LatestWorkflowRunTime: number, Branch: string): Promise { + var MatchedCommitTimeAddress = 0 + if (ProgramOptions.shouldUseApi) { + const GitHubInstance = CreateGitHubInstance(ProgramOptions) + const GitHubListCommitsRaw = await GitHubInstance.repos.listCommits({ + owner: ProgramOptions.repo.split('/')[0], + repo: ProgramOptions.repo.split('/')[1], + sha: Branch, + since: DateTime.fromMillis(LatestWorkflowRunTime).toISO(), + }).then(Response => Response.data) + for (const CommitRaw of GitHubListCommitsRaw) { + if (DateTime.fromISO(CommitRaw.commit.author.date).toMillis() < LatestWorkflowRunTime) { + break + } + + MatchedCommitTimeAddress++ + } + + return GitHubListCommitsRaw[MatchedCommitTimeAddress].sha + } + + if (!ProgramOptions.shouldUseApi) { + const GitInstance = CreateGitInstance(`${ProgramOptions.ciWorkspacePath}/${ProgramOptions.repo.split('/')[1]}`) + const GitLogRaw = (await GitInstance.log(['--date=iso-strict', `--since=${DateTime.fromMillis(LatestWorkflowRunTime).toISO()}`])).all + for (const CommitRaw of GitLogRaw) { + if (DateTime.fromISO(CommitRaw.date).toMillis() < LatestWorkflowRunTime) { + break + } + + MatchedCommitTimeAddress++ + } + + return GitLogRaw[MatchedCommitTimeAddress].hash + } +} + +/** + * @name GetChangedFilesFromSHAToBranchLatestCommits + * @description Get changed files from a commit to the latest commit in a branch. + * @param {Types.ProgramOptionsType} ProgramOptions The program options. + * @param {stirng} CommitSHA The commit SHA. + * @param {string} Branch The branch name. + * @returns {Promise} A list of changed files. + */ +export async function GetChangedFilesFromSHAToHead(ProgramOptions: Types.ProgramOptionsType, CommitSHA: string, Branch: string): Promise { + if (ProgramOptions.shouldUseApi) { + const GitHubInstance = CreateGitHubInstance(ProgramOptions) + const GitHubComparingRaw = await GitHubInstance.repos.compareCommits({ + owner: ProgramOptions.repo.split('/')[0], + repo: ProgramOptions.repo.split('/')[1], + head: Branch, + base: CommitSHA, + }).then(Response => Response.data) + return GitHubComparingRaw.files.map(File => File.filename) + } + + if (!ProgramOptions.shouldUseApi) { + const GitInstance = CreateGitInstance(`${ProgramOptions.ciWorkspacePath}/${ProgramOptions.repo.split('/')[1]}`) + const ChangedFiles = (await GitInstance.diff(['--name-only', `${CommitSHA}...${Branch}`])).split('\n') + return ChangedFiles + } +} diff --git a/sources/debug.ts b/sources/debug.ts new file mode 100644 index 0000000..dc3ef86 --- /dev/null +++ b/sources/debug.ts @@ -0,0 +1,11 @@ +import * as Actions from '@actions/core' +import type * as Types from './types' + +export function IsDebug(Args: Types.ProgramOptionsType | Types.ProgramOptionsRawType) { + const ArgsDebug = typeof Args.debug === 'string' ? Args.debug === 'true' : Args.debug + return Actions.isDebug() || ArgsDebug +} + +export function ExportArgs(Args: Types.ProgramOptionsType | Types.ProgramOptionsRawType) { + Actions.debug(`ProgramOptions: ${JSON.stringify(Args).replace(/(?<=,"ghToken":")[^"]+/, '***')}`) +} diff --git a/sources/requests.ts b/sources/requests.ts new file mode 100644 index 0000000..dc5f107 --- /dev/null +++ b/sources/requests.ts @@ -0,0 +1,111 @@ +import * as Got from 'got' +import * as Actions from '@actions/core' +import * as Os from 'node:os' +import PQueue from 'p-queue' +import {IsDebug} from './debug' +import * as Utility from './utility' +import type * as Types from './types' + +async function GetCDNResponse(ProgramOptions: Types.ProgramOptionsType, ID: string): Promise { + const ResponseRaw: Types.CDNStatusResponseType = await Got.got(`https://purge.jsdelivr.net/status/${ID}`, { + https: { + minVersion: 'TLSv1.3', + ciphers: 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256', + }, + http2: true, + }).json() + if (IsDebug(ProgramOptions)) { + Actions.debug(`GetCDNResponse in requests.ts called: ${JSON.stringify(ResponseRaw)}`) + } + + return ResponseRaw +} + +async function PostPurgeRequest(ProgramOptions: Types.ProgramOptionsType, BranchOrTag: string[], Filenames: string[]): Promise { + const ResponseRaw: Types.CDNPostResponseType = await Got.got.post('https://purge.jsdelivr.net/', { + headers: { + 'cache-control': 'no-cache', + }, + json: { + path: new Array(Filenames.length).fill(null, 0, Filenames.length).map((Filename, Index) => `/gh/${ProgramOptions.repo}@${BranchOrTag[Index]}/${Filenames[Index]}`), + } satisfies Types.CDNPostRequestType, + https: { + minVersion: 'TLSv1.3', + ciphers: 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256', + }, + http2: true, + }).json() + if (IsDebug(ProgramOptions)) { + Actions.debug(`PostPurgeRequest in requests.ts called: ${JSON.stringify(ResponseRaw)}`) + } + + return ResponseRaw +} + +export class PurgeRequestManager { + private readonly SharedPQueue = new PQueue({autoStart: false, concurrency: Os.cpus().length}) + private readonly RemainingFilenames: Types.RemainingFilenamesArrayType[] = [] + + constructor(private readonly ProgramOptions: Types.ProgramOptionsType) {} + + AddURLs(Filenames: string[], BranchOrTag: string) { + const SplittedFilenames = Utility.GroupStringsByNumber(Filenames.map(Filename => ({Filename, BranchOrTag})), 20) as Types.RemainingFilenamesArrayType[][] + + if (SplittedFilenames[SplittedFilenames.length - 1].length < 20) { + this.RemainingFilenames.push(...SplittedFilenames.pop()) + } + + for (const SplittedFilenameGroup of SplittedFilenames) { + void this.SharedPQueue.add(async () => { + const CDNRequestArary: Types.CDNPostResponseType[] = [] + while (CDNRequestArary.length === 0 || !CDNRequestArary.some(async CDNResponse => (await GetCDNResponse(this.ProgramOptions, CDNResponse.id)).status === 'finished' + || (await GetCDNResponse(this.ProgramOptions, CDNResponse.id)).status === 'failed')) { + // eslint-disable-next-line no-await-in-loop + const CDNRequest: Types.CDNPostResponseType = await PostPurgeRequest(this.ProgramOptions, new Array(20).fill(BranchOrTag, 0, 20) as string[], SplittedFilenameGroup.map(SplittedFilename => SplittedFilename.Filename)) + CDNRequestArary.push(CDNRequest) + // eslint-disable-next-line no-await-in-loop + await new Promise(Resolve => { + setTimeout(Resolve, 2500) + }) + } + + Actions.info(`Queue: jsDelivr server returns that the following files are purged: + ${SplittedFilenameGroup.map(Filename => `@${Filename.BranchOrTag}/${Filename.Filename}`).join('\n - ').replace(/^/, ' - ')} + `) + }) + } + } + + RunningJobs(): number { + return this.SharedPQueue.pending + } + + WaitingJobs(): number { + return this.SharedPQueue.size + } + + Start(): void { + const RemainingFilenamesGroup = Utility.GroupStringsByNumber(this.RemainingFilenames, 20) as Types.RemainingFilenamesArrayType[][] + for (const RemainingFilenames of RemainingFilenamesGroup) { + void this.SharedPQueue.add(async () => { + const CDNRequestArary: Types.CDNPostResponseType[] = [] + while (CDNRequestArary.length === 0 || !CDNRequestArary.some(async CDNResponse => (await GetCDNResponse(this.ProgramOptions, CDNResponse.id)).status === 'finished' + || (await GetCDNResponse(this.ProgramOptions, CDNResponse.id)).status === 'failed')) { + // eslint-disable-next-line no-await-in-loop + const CDNRequest: Types.CDNPostResponseType = await PostPurgeRequest(this.ProgramOptions, RemainingFilenames.map(RemainingFilename => RemainingFilename.BranchOrTag), RemainingFilenames.map(RemainingFilename => RemainingFilename.Filename)) + CDNRequestArary.push(CDNRequest) + // eslint-disable-next-line no-await-in-loop + await new Promise(Resolve => { + setTimeout(Resolve, 2500) + }) + } + + Actions.info(`Queue: jsDelivr server returns that the following files are purged: + ${RemainingFilenames.map(Filename => `@${Filename.BranchOrTag}/${Filename.Filename}`).join('\n - ').replace(/^/, ' - ')} + `) + }) + } + + this.SharedPQueue.start() + } +} diff --git a/sources/types.ts b/sources/types.ts new file mode 100644 index 0000000..b7b2619 --- /dev/null +++ b/sources/types.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export type ProgramOptionsRawType = { + debug: boolean; + ghToken: string; + repo: string; + workflowRef: string; + branch: string; + ciWorkspacePath: string; + ciActionPath: string; + shouldUseApi: 'true' | 'false'; +} + +export type ProgramOptionsType = { + debug: boolean; + ghToken: string; + repo: string; + workflowRef: string; + branch: string; + ciWorkspacePath: string; + ciActionPath: string; + shouldUseApi: boolean; +} + +export type CDNStatusResponseType = { + id: string; + status: 'pending' | 'finished' | 'failed'; + paths: Record; +} + +export type CDNPostResponseType = { + id: string; + status: 'pending' | 'finished' | 'failed'; + timestamp: string; +} + +export type CDNPostRequestType = { + path: string[]; +} + +export type RemainingFilenamesArrayType = { + Filename: string; + BranchOrTag: string; +} diff --git a/sources/utility.ts b/sources/utility.ts new file mode 100644 index 0000000..e2573cc --- /dev/null +++ b/sources/utility.ts @@ -0,0 +1,53 @@ +/** + * @name ReplaceStringWithBooleanInObject + * @description Replace string-based boolean properties with real booleans in an object. + * @param {unknown} Source A Object. + * @returns {unknown} A modified object. + */ +export function ReplaceStringWithBooleanInObject(Source: unknown): unknown { + for (const Property of Object.keys(Source)) { + switch (Source[Property]) { + case 'true': + Source[Property] = true + break + case 'false': + Source[Property] = false + break + default: + break + } + + if (typeof Source[Property] === 'object') { + ReplaceStringWithBooleanInObject(Source[Property]) + } + } + + return Source +} + +/** + * @name IncludePropertiesInObject + * @description Check if an same object exists in an array. + * @param {unknown[]} CustomObjectArray An array of objects. + * @param {unknown} CompareObject An object to compare. + * @returns {boolean} The calculated boolean. + */ +export function IncludePropertiesInObject(CustomObjectArray: unknown[], CompareObject: unknown): boolean { + return CustomObjectArray.some(CustomObject => Object.entries(CompareObject).every(([Key, Value]) => CustomObject[Key] === Value)) +} + +/** + * @name GroupStringsByNumber + * @description Groups an array of strings into subarrays based on a specified group size. + * @param {string[]} Strings - The array of strings to be grouped. + * @param {number} GroupSize - The size of each group. + * @returns {string[][]} An array of subarrays, where each subarray contains a group of strings. + */ +export function GroupStringsByNumber(StringOrObject: unknown[], GroupSize: number): unknown[][] { + const SplittedArray = new Array(Math.ceil(StringOrObject.length / GroupSize)) + for (var I = 0; I < SplittedArray.length; I++) { + SplittedArray[I] = StringOrObject.slice(I === 0 ? I : I * GroupSize, (I + 1) * GroupSize > StringOrObject.length ? StringOrObject.length : (I + 1) * GroupSize) + } + + return SplittedArray +} diff --git a/threads.ts b/threads.ts deleted file mode 100644 index 7142818..0000000 --- a/threads.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as Actions from '@actions/core' -import * as Got from 'got' -import * as GitHub from '@octokit/rest' -import * as Threads from 'worker_threads' -import * as Dotenv from 'dotenv' -import { DateTime } from 'luxon' - -Dotenv.config() - -Threads.parentPort.on('message', async (Message: string) => { - Actions.info(`Thread handling ${Message} started.`) - - // Variables - const Octokit = new GitHub.Octokit({ auth: process.env['GITHUB_TOKEN'] }) - const [RepoOwner, RepoName] = process.env['GITHUB_REPO'].split('/') - var ChangedFiles:string[] = [] - let LatestWorkflowRunTime:number = Number.MIN_SAFE_INTEGER - let MatchedCommitTimeAddr:number = 0 - - // Check GitHub workflow history to calcuate duration of commits. - await Octokit.rest.actions.listWorkflowRuns({ - owner: RepoOwner, repo: RepoName, workflow_id: process.env['GITHUB_WORKFLOW_REF'].match(new RegExp(`(?<=${process.env['GITHUB_REPO']}\/\.github\/workflows\/).+\.yml(?=@refs\/)`))[0], - per_page: Number.MAX_SAFE_INTEGER }).then(async (ListWorkflowRuns) => { - for (const Run of ListWorkflowRuns.data.workflow_runs) { - if (Run.status === 'completed' && Run.conclusion === 'success' && - DateTime.fromISO(Run.updated_at).toMillis() > LatestWorkflowRunTime) { - LatestWorkflowRunTime = DateTime.fromISO(Run.updated_at).toMillis() - } - } - }) - - // Calcuate time including the delay. - if (LatestWorkflowRunTime === Number.MIN_SAFE_INTEGER) { - LatestWorkflowRunTime = Date.now() - Actions.info(`This workflow run is first jsdelivr-purge run of ${process.env['GITHUB_REPO']}.`) - } - - const DateTimeDelay = DateTime.fromFormat(process.env['INPUT_DELAY'], 'H:m:s') - const CommitTime:DateTime = DateTime.fromMillis(LatestWorkflowRunTime).minus({ - hours: DateTimeDelay.hour, - minutes: DateTimeDelay.minute, - seconds: DateTimeDelay.second - }) - - // Get a list of changed files during the duration. - await Octokit.rest.repos.listCommits({ owner: RepoOwner, repo: RepoName, per_page: Number.MAX_SAFE_INTEGER }) - .then(async (ListCommitsData) => { - if (ListCommitsData.data.length !== 0) { - for (let i = 0; i < ListCommitsData.data.length; i++) { - if (DateTime.fromISO(ListCommitsData.data[i].commit.author.date).toMillis() < CommitTime.toMillis()) { - MatchedCommitTimeAddr = i - break - } - } - await Octokit.rest.repos.compareCommits({ owner: RepoOwner, repo: RepoName, head: `${RepoOwner}:${Message}`, base: `${RepoOwner}:${ListCommitsData.data[MatchedCommitTimeAddr].sha}` }) - .then(CompareData => ChangedFiles = CompareData.data.files.map(Files => Files.filename)) - } - }) - - if (!ChangedFiles.length) { - Actions.info(`Thread for ${Message}: No files changes found. Exiting...`) - process.exit(0) - } - - Actions.info(`Thread for ${Message}: Found files changes during from ${CommitTime.toISO()}: - ${ChangedFiles.join('\n - ').replace(/^/, ' - ')} - `) - - // Set new array by splitting with each of the 20 items. - var SplittedChangedFiles:Array = new Array(Math.ceil(ChangedFiles.length / 20)) - for (let i = 0; i < SplittedChangedFiles.length; i++) { - SplittedChangedFiles[i] = ChangedFiles.slice(i === 0 ? i : i * 20, (i + 1) * 20 > ChangedFiles.length ? ChangedFiles.length : (i + 1) * 20) - } - ChangedFiles = null - - // Make requests - for (let Changed of SplittedChangedFiles) { - let CDNResponses:Array = [] - while(CDNResponses.length === 0 || - !(CDNResponses.some(async (CDNResponse) => { - let CDNStatus:JSON = await Got.got.get(`https://purge.jsdelivr.net/status/${CDNResponse}`, { https: { minVersion: 'TLSv1.3' }, http2: true }).json() - return CDNStatus['status'] === 'finished' || CDNStatus['status'] === 'failed'})) - ) { - let CDNRequest:JSON = await Got.got.post('https://purge.jsdelivr.net/', { - headers: { 'cache-control': 'no-cache' }, - json: { - 'path': Changed.map(element => `/gh/${RepoOwner}/${RepoName}@${Message}/${element}`) - }, - https: { minVersion: 'TLSv1.3' }, http2: true }).json() - Actions.info(`Thread for ${Message}: Sent new request having ${CDNRequest['id']} ID.`) - CDNResponses.push(CDNRequest['id']) - await new Promise(resolve => setTimeout(resolve, 5000)) - } - Actions.info(`Thread for ${Message}: jsDelivr server returns that the following files are purged: - ${Changed.join('\n - ').replace(/^/, ' - ')} - `) - } - - Actions.info(`Thread for ${Message}: All changed files are purged. Exiting...`) - await new Promise(resolve => setTimeout(resolve, 1000)) - process.exit(0) -}) \ No newline at end of file