diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index 1da5a3e..4381e08 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -24,6 +24,9 @@ on: bumped_version: description: "The version after bumping" value: ${{ jobs.bump.outputs.bumped_version }} + commit_sha: + description: "The commit this job pushed, for downstream jobs to build and tag" + value: ${{ jobs.bump.outputs.commit_sha }} jobs: bump: @@ -31,6 +34,7 @@ jobs: environment: ${{ inputs.environment }} outputs: bumped_version: ${{ steps.get-version.outputs.bumped_version }} + commit_sha: ${{ steps.commit.outputs.commit_sha }} steps: - name: Create deployooor app token uses: actions/create-github-app-token@v1 @@ -93,8 +97,11 @@ jobs: run: echo "bumped_version=$(jq -r .version package.json)" >> $GITHUB_OUTPUT - name: Commit and push + id: commit env: LOCKFILE: ${{ steps.pm.outputs.lockfile }} + VERSION: ${{ steps.get-version.outputs.bumped_version }} + REF: ${{ inputs.ref }} run: | git config --local user.email "deployooor@nexusmutual.io" git config --local user.name "Version bumpooor" @@ -107,5 +114,9 @@ jobs: fi git add package.json "$LOCKFILE" $(git ls-files "**/package.json" "**/$LOCKFILE") - git commit -m "chore(release): v${{ steps.get-version.outputs.bumped_version }} [skip ci]" - git push origin ${{ inputs.ref }} + git commit -m "chore(release): v${VERSION} [skip ci]" + git push origin "$REF" + + # Downstream jobs build and tag this exact commit, rather than whatever the + # branch tip holds by the time they start. + echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/check-tag-version-equality.yml b/.github/workflows/check-tag-version-equality.yml index 8ea9fbb..4123d9c 100644 --- a/.github/workflows/check-tag-version-equality.yml +++ b/.github/workflows/check-tag-version-equality.yml @@ -37,8 +37,9 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} ref: ${{ inputs.ref }} - # Need git tags to determine version comparison - fetch-tags: true + # git describe walks back from HEAD to find a tag, so it needs the history. + # Depth 0 brings the tags along with it. + fetch-depth: 0 - name: Get package.json version id: package-version diff --git a/.github/workflows/check-workflow-status.yml b/.github/workflows/check-workflow-status.yml index 6611869..3726946 100644 --- a/.github/workflows/check-workflow-status.yml +++ b/.github/workflows/check-workflow-status.yml @@ -11,7 +11,11 @@ on: workflow-status: type: string required: true - description: "Status to check for (e.g., in_progress, queued, completed)" + description: "Status to check for (e.g., in_progress, queued, completed), or non-terminal for any run still going" + created-within-minutes: + type: number + default: 0 + description: "Only consider runs started within this many minutes. 0 considers every run." fail-if-exists: type: boolean default: true @@ -30,22 +34,85 @@ jobs: steps: - name: Check workflow status uses: actions/github-script@v7 + # Inputs reach the script through the environment, keeping them out of the source. + env: + WORKFLOW_NAME: ${{ inputs.workflow-name }} + WORKFLOW_STATUS: ${{ inputs.workflow-status }} + CREATED_WITHIN_MINUTES: ${{ inputs.created-within-minutes }} + FAIL_IF_EXISTS: ${{ inputs.fail-if-exists }} + ERROR_MESSAGE: ${{ inputs.error-message }} with: github-token: ${{ inputs.token || github.token }} script: | - const workflows = await github.rest.actions.listWorkflowRunsForRepo({ + const workflowName = process.env.WORKFLOW_NAME; + const status = process.env.WORKFLOW_STATUS; + const windowMinutes = Number(process.env.CREATED_WITHIN_MINUTES) || 0; + const failIfExists = process.env.FAIL_IF_EXISTS === 'true'; + + // A run blocked by a concurrency group reports queued or pending depending on + // how it was triggered, so non-terminal asks for each of these in turn. + const NON_TERMINAL = ['queued', 'in_progress', 'waiting', 'pending', 'requested']; + + // The API returns an empty list for a status it fails to recognise, which would + // pass this guard silently. Accept only the values it documents. + const ALLOWED = NON_TERMINAL.concat([ + 'completed', + 'action_required', + 'cancelled', + 'failure', + 'neutral', + 'skipped', + 'stale', + 'success', + 'timed_out', + ]); + + if (status !== 'non-terminal' && !ALLOWED.includes(status)) { + core.setFailed(`Unknown workflow-status "${status}". Use non-terminal, or one of: ${ALLOWED.join(', ')}`); + return; + } + + const params = { owner: context.repo.owner, repo: context.repo.repo, - workflow_id: '${{ inputs.workflow-name }}', - status: '${{ inputs.workflow-status }}', - }); - - if (workflows.data.total_count > 0) { - if (${{ inputs.fail-if-exists }}) { - core.setFailed(`${{ inputs.workflow-name }}: ${{ inputs.error-message }}`); - } else { - console.log(`Found ${{ inputs.workflow-status }} workflows for ${{ inputs.workflow-name }}. Continuing as configured.`); + workflow_id: workflowName, + per_page: 100, + }; + + if (windowMinutes > 0) { + params.created = `>=${new Date(Date.now() - windowMinutes * 60 * 1000).toISOString()}`; + } + + // listWorkflowRuns scopes to workflow_id. The repo-level endpoint drops it and + // returns runs for every workflow, dependabot included. + // total_count covers every page, so the tally stays exact. + let total = 0; + const found = []; + + for (const runStatus of status === 'non-terminal' ? NON_TERMINAL : [status]) { + let data; + try { + ({ data } = await github.rest.actions.listWorkflowRuns({ ...params, status: runStatus })); + } catch (error) { + if (error.status === 404) { + core.setFailed(`Workflow ${workflowName} not found in ${context.repo.owner}/${context.repo.repo}`); + return; + } + throw error; } + total += data.total_count; + found.push(...data.workflow_runs.map(run => `${run.name} ${run.status} (${run.html_url})`)); + } + + if (total === 0) { + console.log(`No ${status} runs found for ${workflowName}. Proceeding.`); + return; + } + + const detail = found.join('\n'); + + if (failIfExists) { + core.setFailed(`${workflowName}: ${process.env.ERROR_MESSAGE}\n${detail}`); } else { - console.log(`No ${{ inputs.workflow-status }} workflows found for ${{ inputs.workflow-name }}. Proceeding.`); - } \ No newline at end of file + console.log(`Found ${total} ${status} run(s) for ${workflowName}. Continuing as configured.\n${detail}`); + } diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..5ec171c --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,34 @@ +name: pull-request + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: '22' + +jobs: + checks: + if: github.event.pull_request.draft == false + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci --ignore-scripts --prefer-offline --no-audit --no-fund + + - name: Test + run: npm test diff --git a/.github/workflows/tag-image.yml b/.github/workflows/tag-image.yml index bba0a18..c01fa6b 100644 --- a/.github/workflows/tag-image.yml +++ b/.github/workflows/tag-image.yml @@ -8,13 +8,18 @@ on: required: true ref: type: string - required: true + default: "" + description: "Ref to read version and sha from. Set this or source-tag." image: type: string required: true target-tag: type: string required: true + source-tag: + type: string + default: "" + description: "Tag to promote, e.g. needs.build-image.outputs.image-tag. Defaults to - read from ref." secrets: TOKEN: required: true @@ -28,17 +33,31 @@ jobs: runs-on: ubuntu-22.04 environment: ${{ inputs.environment }} steps: + # Only a caller relying on ref needs the checkout, to read version and sha from it. - uses: actions/checkout@v4 + if: inputs.source-tag == '' with: token: ${{ secrets.TOKEN }} ref: ${{ inputs.ref }} - name: Get version and revision id: vars + env: + SOURCE_TAG: ${{ inputs.source-tag }} + REF: ${{ inputs.ref }} run: | - VERSION=$(jq -r .version package.json) - SHA_SHORT=$(git rev-parse --short HEAD) - echo "SOURCE_TAG=${VERSION}-${SHA_SHORT}" >> $GITHUB_OUTPUT + # Reading version and sha from ref resolves against the branch tip, which moves. + # Callers that already know the tag they pushed pass it in instead. + if [ -z "$SOURCE_TAG" ]; then + if [ -z "$REF" ]; then + echo "::error::Set either ref or source-tag" + exit 1 + fi + VERSION=$(jq -r .version package.json) + SHA_SHORT=$(git rev-parse --short HEAD) + SOURCE_TAG="${VERSION}-${SHA_SHORT}" + fi + echo "SOURCE_TAG=${SOURCE_TAG}" >> $GITHUB_OUTPUT echo 'IMAGE=${{ inputs.image }}' | tr '[:upper:]' '[:lower:]' >> $GITHUB_OUTPUT - name: Tag image diff --git a/.gitignore b/.gitignore index 090a1f0..0ac92e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea .DS_Store +node_modules diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1c688ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "@nexusmutual/workflows", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nexusmutual/workflows", + "version": "0.0.0", + "devDependencies": { + "js-yaml": "^4.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e195a97 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nexusmutual/workflows", + "version": "0.0.0", + "private": true, + "description": "Reusable GitHub Actions workflows for NexusMutual repositories", + "scripts": { + "test": "node --test" + }, + "devDependencies": { + "js-yaml": "^4.1.0" + } +} diff --git a/test/check-workflow-status.test.js b/test/check-workflow-status.test.js new file mode 100644 index 0000000..a6678fb --- /dev/null +++ b/test/check-workflow-status.test.js @@ -0,0 +1,202 @@ +const { test, describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); +const yaml = require("js-yaml"); + +// The logic under test lives in a github-script `script:` block, so read it out of +// the workflow and run it against a stubbed octokit. +const WORKFLOW = path.join( + __dirname, + "..", + ".github", + "workflows", + "check-workflow-status.yml", +); +const script = yaml.load(fs.readFileSync(WORKFLOW, "utf8")).jobs[ + "wait-for-workflow" +].steps[0].with.script; + +const NON_TERMINAL = [ + "queued", + "in_progress", + "waiting", + "pending", + "requested", +]; + +/** + * Run the script with the given inputs. + * byStatus maps a run status to the total_count the API reports for it. + */ +async function run({ env = {}, byStatus = {}, throwStatus } = {}) { + const calls = []; + const failures = []; + const logs = []; + + const github = { + rest: { + actions: { + listWorkflowRuns: async (params) => { + calls.push(params); + if (throwStatus) { + const error = new Error("request failed"); + error.status = throwStatus; + throw error; + } + const total = byStatus[params.status] || 0; + return { + data: { + total_count: total, + // The API caps a page at per_page, while total_count spans every page. + workflow_runs: Array.from( + { length: Math.min(total, params.per_page || 100) }, + () => ({ + name: "Merge to dev or hotfix", + status: params.status, + html_url: "https://github.test/run/1", + }), + ), + }, + }; + }, + listWorkflowRunsForRepo: async () => { + throw new Error( + "the repo-level endpoint ignores workflow_id and must stay unused", + ); + }, + }, + }, + }; + + const context = { repo: { owner: "NexusMutual", repo: "demo" } }; + const core = { setFailed: (message) => failures.push(message) }; + + const previousLog = console.log; + console.log = (message) => logs.push(message); + + Object.assign(process.env, { + WORKFLOW_NAME: "merge-to-dev-or-hotfix.yml", + WORKFLOW_STATUS: "", + CREATED_WITHIN_MINUTES: "0", + FAIL_IF_EXISTS: "true", + ERROR_MESSAGE: "Workflow is currently running.", + ...env, + }); + + // The script body is the workflow file in this repo, and running it is the point of + // the test. github, context and core arrive as arguments, the way github-script + // supplies them. + const fn = new Function( + "github", + "context", + "core", + `"use strict"; return (async () => {${script}})();`, + ); + + try { + await fn(github, context, core); + } finally { + console.log = previousLog; + } + + return { calls, failures, logs }; +} + +describe("check-workflow-status", () => { + test("scopes the query to the named workflow", async () => { + const { calls, failures } = await run({ + env: { WORKFLOW_STATUS: "in_progress" }, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].workflow_id, "merge-to-dev-or-hotfix.yml"); + assert.equal(calls[0].status, "in_progress"); + assert.equal(failures.length, 0); + }); + + test("counts with total_count, which spans every page", async () => { + const { failures } = await run({ + env: { WORKFLOW_STATUS: "in_progress" }, + byStatus: { in_progress: 250 }, + }); + assert.equal(failures.length, 1); + }); + + test("non-terminal asks for each unfinished status", async () => { + const { calls, failures } = await run({ + env: { WORKFLOW_STATUS: "non-terminal" }, + }); + assert.deepEqual( + calls.map((call) => call.status), + NON_TERMINAL, + ); + assert.equal(failures.length, 0); + }); + + for (const status of NON_TERMINAL) { + test(`non-terminal catches a run that is ${status}`, async () => { + const { failures } = await run({ + env: { WORKFLOW_STATUS: "non-terminal" }, + byStatus: { [status]: 1 }, + }); + assert.equal(failures.length, 1); + }); + } + + test("non-terminal leaves finished runs alone", async () => { + const { calls, failures } = await run({ + env: { WORKFLOW_STATUS: "non-terminal" }, + byStatus: { completed: 99 }, + }); + assert.ok(calls.every((call) => call.status !== "completed")); + assert.equal(failures.length, 0); + }); + + test("an unrecognised status fails before any request", async () => { + const { calls, failures } = await run({ + env: { WORKFLOW_STATUS: "in_progres" }, + }); + assert.match(failures[0], /Unknown workflow-status/); + assert.equal(calls.length, 0); + }); + + test("created-within-minutes bounds every query", async () => { + const { calls } = await run({ + env: { WORKFLOW_STATUS: "non-terminal", CREATED_WITHIN_MINUTES: "60" }, + }); + assert.ok(calls.every((call) => /^>=\d{4}-\d{2}-\d{2}T/.test(call.created))); + }); + + test("a window of zero sends no created filter", async () => { + const { calls } = await run({ env: { WORKFLOW_STATUS: "non-terminal" } }); + assert.ok(calls.every((call) => call.created === undefined)); + }); + + test("a missing workflow fails with a clear message", async () => { + const { failures } = await run({ + env: { WORKFLOW_STATUS: "in_progress" }, + throwStatus: 404, + }); + assert.match(failures[0], /not found/); + }); + + test("fail-if-exists false reports and continues", async () => { + const { failures, logs } = await run({ + env: { WORKFLOW_STATUS: "in_progress", FAIL_IF_EXISTS: "false" }, + byStatus: { in_progress: 2 }, + }); + assert.equal(failures.length, 0); + assert.match(logs.join("\n"), /Continuing as configured/); + }); + + test("error-message reaches the failure", async () => { + const { failures } = await run({ + env: { + WORKFLOW_STATUS: "in_progress", + ERROR_MESSAGE: "Release blocked, retry later.", + }, + byStatus: { in_progress: 1 }, + }); + assert.match(failures[0], /Release blocked, retry later\./); + }); +});