From 67072ab2ae2b4e079290b06a0b3a24154621eecd Mon Sep 17 00:00:00 2001 From: laurenhitchon Date: Wed, 29 Jul 2026 19:02:51 +1000 Subject: [PATCH] feat(ci): harden the reusable workflows and stubs - pin the runtime commitlint installs to exact versions - npx --no-install for vitest and semantic-release (fail loudly, never fetch latest) - timeout-minutes on every central job - explicit stub secret mappings replacing secrets: inherit (4 stubs pass none) - format gate job in reusable-ci (prettier --check, CI_SKIP_FORMAT opt-out) - alert-on-failure issue-filing job in reusable-release (ported from nswds-ui) --- .github/workflows/ai-pr-title.yml | 3 +- .github/workflows/ccc-v10-canary.yml | 1 + .github/workflows/ci.yml | 2 + .github/workflows/commit-types-sync.yml | 1 - .github/workflows/commitlint.yml | 1 - .github/workflows/confluence-sync.yml | 1 + .github/workflows/openai-pr-description.yml | 3 +- .github/workflows/release.yml | 3 +- .github/workflows/reusable-ai-pr-title.yml | 9 ++- .github/workflows/reusable-ci.yml | 59 ++++++++++++++++++- .../workflows/reusable-commit-types-sync.yml | 4 +- .github/workflows/reusable-commitlint.yml | 1 + .../reusable-openai-pr-description.yml | 3 +- .github/workflows/reusable-release.yml | 55 ++++++++++++++++- .../reusable-validate-branch-name.yml | 1 + .github/workflows/sync.yml | 1 + .github/workflows/validate-branch-name.yml | 1 - workflow-stubs/ai-pr-title.yml | 5 +- workflow-stubs/ci.yml | 1 - workflow-stubs/commit-types-sync.yml | 1 - workflow-stubs/commitlint.yml | 1 - workflow-stubs/openai-pr-description.yml | 5 +- workflow-stubs/release.yml | 5 +- workflow-stubs/validate-branch-name.yml | 1 - 24 files changed, 147 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ai-pr-title.yml b/.github/workflows/ai-pr-title.yml index e851a72..04905c4 100644 --- a/.github/workflows/ai-pr-title.yml +++ b/.github/workflows/ai-pr-title.yml @@ -13,4 +13,5 @@ permissions: jobs: generate-title: uses: ./.github/workflows/reusable-ai-pr-title.yml - secrets: inherit + secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/.github/workflows/ccc-v10-canary.yml b/.github/workflows/ccc-v10-canary.yml index 79f7634..c5b437c 100644 --- a/.github/workflows/ccc-v10-canary.yml +++ b/.github/workflows/ccc-v10-canary.yml @@ -22,6 +22,7 @@ permissions: jobs: probe: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2ab8a5..675cd7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ permissions: jobs: shellcheck: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v7 - name: Shellcheck all shared scripts @@ -27,6 +28,7 @@ jobs: workflow-lint: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v7 - name: actionlint (workflows + stubs) diff --git a/.github/workflows/commit-types-sync.yml b/.github/workflows/commit-types-sync.yml index 79e2aad..7a5a068 100644 --- a/.github/workflows/commit-types-sync.yml +++ b/.github/workflows/commit-types-sync.yml @@ -25,4 +25,3 @@ permissions: jobs: check: uses: ./.github/workflows/reusable-commit-types-sync.yml - secrets: inherit diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 777af1d..6da74bb 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -11,4 +11,3 @@ permissions: jobs: commitlint: uses: ./.github/workflows/reusable-commitlint.yml - secrets: inherit diff --git a/.github/workflows/confluence-sync.yml b/.github/workflows/confluence-sync.yml index bd8e43b..add90b1 100644 --- a/.github/workflows/confluence-sync.yml +++ b/.github/workflows/confluence-sync.yml @@ -19,6 +19,7 @@ permissions: jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 10 # One publish at a time; a queued push supersedes an unstarted older one. concurrency: group: confluence-sync diff --git a/.github/workflows/openai-pr-description.yml b/.github/workflows/openai-pr-description.yml index 481d3ae..5615907 100644 --- a/.github/workflows/openai-pr-description.yml +++ b/.github/workflows/openai-pr-description.yml @@ -13,4 +13,5 @@ permissions: jobs: openai-pr-description: uses: ./.github/workflows/reusable-openai-pr-description.yml - secrets: inherit + secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30f0e95..7591567 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,4 +15,5 @@ permissions: jobs: release: uses: ./.github/workflows/reusable-release.yml - secrets: inherit + secrets: + RELEASE_DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }} diff --git a/.github/workflows/reusable-ai-pr-title.yml b/.github/workflows/reusable-ai-pr-title.yml index 9ba10cb..3165c6f 100755 --- a/.github/workflows/reusable-ai-pr-title.yml +++ b/.github/workflows/reusable-ai-pr-title.yml @@ -11,12 +11,13 @@ on: default: '24' secrets: OPENAI_API_KEY: - description: "Supplied automatically by the stubs' `secrets: inherit`." + description: "Mapped explicitly by the stubs (least privilege — not inherit)." required: false jobs: generate-title: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read @@ -80,7 +81,11 @@ jobs: - name: Install commitlint if: ${{ steps.commitlint-config.outputs.present == 'true' && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name }} shell: bash - run: npm install --no-save --no-fund --no-audit --loglevel=error @commitlint/cli @commitlint/config-conventional + # Pinned exact: an unpinned install here executes whatever npm serves + # as latest, on every PR, fleet-wide — the same class of risk the + # SHA-pinned actions policy exists for. Keep in step with the + # devDependency majors in package.json when Renovate bumps them. + run: npm install --no-save --no-fund --no-audit --loglevel=error @commitlint/cli@21.2.1 @commitlint/config-conventional@21.2.0 - name: Get Current PR Title id: get-pr-title diff --git a/.github/workflows/reusable-ci.yml b/.github/workflows/reusable-ci.yml index 87c1f83..6c86906 100644 --- a/.github/workflows/reusable-ci.yml +++ b/.github/workflows/reusable-ci.yml @@ -21,6 +21,7 @@ permissions: jobs: install: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v7 @@ -89,6 +90,7 @@ jobs: lint: if: ${{ vars.CI_SKIP_LINT != 'true' }} runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v7 @@ -109,6 +111,58 @@ jobs: - name: Lint run: npm run lint --if-present + # Format gate. The fleet's Prettier options are centralised in + # @nswds/prettier-config, but every repo's `format` script is write-mode — + # without a CI check the standard is convention, and drift only surfaces as + # noisy style diffs in later PRs (awards#74 needed a follow-up style commit + # for exactly this). Runs only when the repo has a Prettier config: a + # config file, or the package.json "prettier" key — the latter is how + # nswds-tokens and the config packages consume it, and workflow expressions + # can't read package.json, hence the probe step (same pattern as the + # npm-test probe below). Repos with latent unformatted files opt out with + # CI_SKIP_FORMAT=true while they sweep. + # Not required by rulesets unless a repo adds the "install / format" context. + format: + if: ${{ vars.CI_SKIP_FORMAT != 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - name: Detect Prettier config + id: prettier + run: | + present=false + if ls .prettierrc .prettierrc.* prettier.config.* >/dev/null 2>&1; then + present=true + elif [ -f package.json ] && node -e "process.exit(require('./package.json').prettier ? 0 : 1)"; then + present=true + fi + echo "present=$present" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js (.nvmrc) + if: ${{ steps.prettier.outputs.present == 'true' && hashFiles('.nvmrc') != '' }} + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + - name: Setup Node.js (fallback) + if: ${{ steps.prettier.outputs.present == 'true' && hashFiles('.nvmrc') == '' }} + uses: actions/setup-node@v7 + with: + node-version: ${{ inputs.node-version }} + + - name: Install dependencies + if: ${{ steps.prettier.outputs.present == 'true' }} + run: npm clean-install + + - name: Check formatting + if: ${{ steps.prettier.outputs.present == 'true' }} + run: npx --no-install prettier --check . + + - name: Nothing to run + if: ${{ steps.prettier.outputs.present != 'true' }} + run: echo "No Prettier config file or package.json prettier key — nothing to check." + # Test gate: runs the repo's vitest suite when a vitest config exists, # otherwise its `npm test` script. Same silent-gap rationale as lint — # nswds-app's storybook browser suite never ran on PRs. Browser-mode suites @@ -127,6 +181,7 @@ jobs: test: if: ${{ vars.CI_SKIP_TESTS != 'true' }} runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v7 @@ -156,7 +211,9 @@ jobs: - name: Run vitest if: ${{ hashFiles('vitest.config.*') != '' }} - run: npx vitest run + # --no-install: a repo with a vitest config but no vitest dependency + # should fail loudly, not have npx fetch and run latest from the registry. + run: npx --no-install vitest run # No vitest config: fall back to the repo's own `npm test`, if it has a # real one. Detected in a step rather than an `if:` expression because diff --git a/.github/workflows/reusable-commit-types-sync.yml b/.github/workflows/reusable-commit-types-sync.yml index 7cd6637..58a57ec 100644 --- a/.github/workflows/reusable-commit-types-sync.yml +++ b/.github/workflows/reusable-commit-types-sync.yml @@ -17,6 +17,7 @@ permissions: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v7 @@ -34,8 +35,9 @@ jobs: # Install commitlint without touching the repo's manifest so the check # runs the same way in this repo (no package.json) and in consumer repos. + # Pinned exact — see the matching note in reusable-ai-pr-title.yml. - name: Install commitlint - run: npm install --no-save @commitlint/cli @commitlint/config-conventional + run: npm install --no-save @commitlint/cli@21.2.1 @commitlint/config-conventional@21.2.0 - name: Check commit type lists are in sync run: ./scripts/check-commit-types-sync.sh diff --git a/.github/workflows/reusable-commitlint.yml b/.github/workflows/reusable-commitlint.yml index ba5503e..696d5e8 100644 --- a/.github/workflows/reusable-commitlint.yml +++ b/.github/workflows/reusable-commitlint.yml @@ -16,6 +16,7 @@ permissions: jobs: commitlint: runs-on: ubuntu-latest + timeout-minutes: 10 steps: # Reusable workflows run in the caller's context: this checks out the # consumer repo, and its own devDependencies provide commitlint. diff --git a/.github/workflows/reusable-openai-pr-description.yml b/.github/workflows/reusable-openai-pr-description.yml index f968e9e..ceee96c 100755 --- a/.github/workflows/reusable-openai-pr-description.yml +++ b/.github/workflows/reusable-openai-pr-description.yml @@ -6,7 +6,7 @@ on: workflow_call: secrets: OPENAI_API_KEY: - description: "Supplied automatically by the stubs' `secrets: inherit`." + description: "Mapped explicitly by the stubs (least privilege — not inherit)." required: false permissions: @@ -16,6 +16,7 @@ permissions: jobs: openai-pr-description: runs-on: ubuntu-24.04 + timeout-minutes: 10 # Skip for fork PRs — secrets are not available there if: github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 92fed5b..4d11bcc 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -16,14 +16,15 @@ on: RELEASE_DEPLOY_KEY: description: >- Optional SSH deploy key (ruleset bypass actor) used to push release - commits to a protected default branch. Supplied automatically by the - stubs' `secrets: inherit` where the repo defines it. + commits to a protected default branch. Mapped explicitly by the + stubs (least privilege — not inherit); empty in repos without it. required: false jobs: release: name: Release runs-on: ubuntu-latest + timeout-minutes: 30 # One release at a time, never cancelled mid-publish (a cancelled # semantic-release can tag without publishing). concurrency: @@ -79,4 +80,52 @@ jobs: # husky hooks must not run against the bot commit (this broke # nswds-tokens releases when it regressed — see tokens #105/#107). HUSKY: 0 - run: npx semantic-release + # --no-install: every fleet repo carries semantic-release as a + # devDependency; a repo missing it should fail here, not have npx + # fetch latest and run it with contents:write and the deploy key. + run: npx --no-install semantic-release + + # Release failures are easy to miss: they happen post-merge where nobody is + # watching, and semantic-release may have already pushed the version tag so + # the repo LOOKS released. File (or bump) a labelled issue so every failure + # generates a notification and a visible artefact in the repo. Ported from + # nswds-ui's bespoke release.yml, where three releases (v1.8.0–v2.1.0) once + # failed without anyone noticing. + alert-on-failure: + name: File release-failure issue + needs: release + if: failure() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Create or update the release-failure issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create release-failure \ + --repo "$GITHUB_REPOSITORY" \ + --color B60205 \ + --description "Automated: a Release workflow run failed" \ + --force + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + body="A Release workflow run failed — a git tag may exist without its release artefacts. + + - Run: ${run_url} + - Commit: ${GITHUB_SHA} + - Repos that publish to npm: check for a git tag without a matching npm version. + + _Filed automatically by the alert-on-failure job in reusable-release.yml._" + existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --label release-failure --state open --json number --jq '.[0].number // empty')" + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$GITHUB_REPOSITORY" --body "$body" + echo "Commented on existing issue #$existing" + else + gh issue create \ + --repo "$GITHUB_REPOSITORY" \ + --title "Release workflow failed" \ + --label release-failure \ + --assignee "$GITHUB_ACTOR" \ + --body "$body" + fi diff --git a/.github/workflows/reusable-validate-branch-name.yml b/.github/workflows/reusable-validate-branch-name.yml index 538aa6c..62c7e86 100644 --- a/.github/workflows/reusable-validate-branch-name.yml +++ b/.github/workflows/reusable-validate-branch-name.yml @@ -11,6 +11,7 @@ permissions: jobs: check-branch-name: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout base branch policy uses: actions/checkout@v7 diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 08f5d6e..cdb7944 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -22,6 +22,7 @@ permissions: jobs: sync: runs-on: ubuntu-latest + timeout-minutes: 15 # One sync at a time; a queued push supersedes an unstarted older one. concurrency: group: repo-sync diff --git a/.github/workflows/validate-branch-name.yml b/.github/workflows/validate-branch-name.yml index 7e6320f..463cc80 100755 --- a/.github/workflows/validate-branch-name.yml +++ b/.github/workflows/validate-branch-name.yml @@ -12,4 +12,3 @@ permissions: jobs: check-branch-name: uses: ./.github/workflows/reusable-validate-branch-name.yml - secrets: inherit diff --git a/workflow-stubs/ai-pr-title.yml b/workflow-stubs/ai-pr-title.yml index 37e653d..e211cac 100644 --- a/workflow-stubs/ai-pr-title.yml +++ b/workflow-stubs/ai-pr-title.yml @@ -13,4 +13,7 @@ permissions: jobs: generate-title: uses: digitalnsw/nswds-devops/.github/workflows/reusable-ai-pr-title.yml@v1 - secrets: inherit + secrets: + # Explicit mapping, not inherit — this workflow needs exactly one secret, + # and inherit would hand every repo secret to the called workflow. + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/workflow-stubs/ci.yml b/workflow-stubs/ci.yml index 565c75f..ec26634 100644 --- a/workflow-stubs/ci.yml +++ b/workflow-stubs/ci.yml @@ -13,4 +13,3 @@ permissions: jobs: install: uses: digitalnsw/nswds-devops/.github/workflows/reusable-ci.yml@v1 - secrets: inherit diff --git a/workflow-stubs/commit-types-sync.yml b/workflow-stubs/commit-types-sync.yml index b2df69e..5a2497d 100644 --- a/workflow-stubs/commit-types-sync.yml +++ b/workflow-stubs/commit-types-sync.yml @@ -24,4 +24,3 @@ permissions: jobs: check: uses: digitalnsw/nswds-devops/.github/workflows/reusable-commit-types-sync.yml@v1 - secrets: inherit diff --git a/workflow-stubs/commitlint.yml b/workflow-stubs/commitlint.yml index db27be6..f5e8235 100644 --- a/workflow-stubs/commitlint.yml +++ b/workflow-stubs/commitlint.yml @@ -11,4 +11,3 @@ permissions: jobs: commitlint: uses: digitalnsw/nswds-devops/.github/workflows/reusable-commitlint.yml@v1 - secrets: inherit diff --git a/workflow-stubs/openai-pr-description.yml b/workflow-stubs/openai-pr-description.yml index 33041e8..437c6a1 100644 --- a/workflow-stubs/openai-pr-description.yml +++ b/workflow-stubs/openai-pr-description.yml @@ -13,4 +13,7 @@ permissions: jobs: openai-pr-description: uses: digitalnsw/nswds-devops/.github/workflows/reusable-openai-pr-description.yml@v1 - secrets: inherit + secrets: + # Explicit mapping, not inherit — this workflow needs exactly one secret, + # and inherit would hand every repo secret to the called workflow. + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/workflow-stubs/release.yml b/workflow-stubs/release.yml index 27a0c9e..3bff13c 100644 --- a/workflow-stubs/release.yml +++ b/workflow-stubs/release.yml @@ -17,4 +17,7 @@ permissions: jobs: release: uses: digitalnsw/nswds-devops/.github/workflows/reusable-release.yml@v1 - secrets: inherit + secrets: + # Explicit mapping, not inherit — the release needs exactly one secret, + # and inherit would hand every repo secret to the called workflow. + RELEASE_DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }} diff --git a/workflow-stubs/validate-branch-name.yml b/workflow-stubs/validate-branch-name.yml index 2d8f6a4..438a2e2 100644 --- a/workflow-stubs/validate-branch-name.yml +++ b/workflow-stubs/validate-branch-name.yml @@ -12,4 +12,3 @@ permissions: jobs: check-branch-name: uses: digitalnsw/nswds-devops/.github/workflows/reusable-validate-branch-name.yml@v1 - secrets: inherit