Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 72 additions & 5 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,69 @@ on the numeric base of the Spring Boot version in the release's properties file,
floor set by the *phase* rather than that Boot version verbatim, so the bound does not churn
on every milestone.

## `version-cmp.js`

`cmp(a, b)`: dotted-numeric version comparison, tolerant of a leading `v` (release tags like
`v0.4.26`). Shared because this exact comparator used to be implemented three times
independently — `update-maven-wrapper.yml`'s `versions` job, `maven-wrapper-properties.js`'s
own `cmp` (now a re-export of this module), and what would otherwise be a fourth copy for the
Antora UI bundle's release tags in `antora-ui-bundle.js`.

## `gh-cli.js`

`gh(args)`, `ghRetry(args, attempts)`, `ghJson(path, method, payload)`: the `execFileSync('gh',
...)` wrapper, retry-with-backoff, and write-payload-then-`--input` helpers every workflow
that fans out `gh api` calls across many repositories needs. `ghJson` writes the payload to
`payload.json` rather than passing it with repeated `-f` flags, because `-f` cannot express
the nested `author`/`committer` objects the git data API needs and would mangle newlines in a
commit message or PR body.

## `git-pr-helpers.js`

The generic git-data-API and pull-request primitives shared by `update-maven-wrapper.yml` and
`update-antora-ui-bundle.yml`, both of which open a bot PR the same way: `readFileAt(repo,
path, ref)` (contents API, base64-decoded), `createBranch(repo, headRef, baseBranch)`,
`commitFilesToRef(repo, ref, files, message, author)` (one commit for every file via the git
data API's tree/commit/ref-move sequence, rather than the contents API's one-file-one-commit
shape), `findOpenPrByHeadPrefix(repo, base, headPrefix)` (matched by prefix rather than exact
head name, so a PR opened for an earlier target is found and bumped in place instead of
stacking a second PR), and `openPr(repo, { title, head, base, body })`. Built on `gh-cli.js`.
What differs per workflow — deciding *whether* something changed, and what the branch/PR
naming and body text say — stays local to each workflow's own script.

## `merge-if-green.js`

`classifyChecks(statusCheckRollup)` and `mergeIfGreen({ repo, pr, method, dryRun })`: reads a
PR's mergeability and checks, and merges it only when every check has reported and passed,
and GitHub reports a `CLEAN` merge state (not merely `MERGEABLE`, which also covers a locked
branch or a pending required review). Checks are read from `statusCheckRollup` rather than
inferred from `mergeStateStatus`, which conflates failing checks with "needs review" and a
locked branch — the same distinction `dependabot-report.yml`'s own PR-state classification
makes independently, in its own `dependabot-scan` action, for Dependabot-authored PRs (a
close cousin of this module, not yet unified with it). Shared by the "Merge PR if all checks
pass" step of `update-maven-wrapper.yml` and `update-antora-ui-bundle.yml`.

## `project-branch-matrix.js`

The `config/projects.json` traversal shared by every workflow that fans out across Spring
Cloud's OSS and commercial repositories: `parseProjectFilter`, `typeKeysFor`, `repoName`,
`knownProjectKeys`, and the underlying `walkProjects` walk, plus two convenience builders for
the two matrix shapes already in use across the estate — `buildBranchMatrix` (one entry per
branch, used by `update-maven-wrapper.yml`, `update-antora-ui-bundle.yml`,
`ci-status-report.yml` and `rollout-actions-ref.yml`) and `buildRepoMatrix` (one entry per
repository with its branches comma-joined, used by `dependabot-report.yml`,
`dependabot-triage.yml` and `lock-unlock-branches.yml`). Before this was extracted, six
workflows each carried their own copy of the same read-filter-walk logic, differing only in
how they turned a project's branches into matrix entries — that's the only part left to each
caller now.

## `maven-wrapper-properties.js`

The rules by which `update-maven-wrapper.yml` edits `maven-wrapper.properties`. It is shared
because that workflow edits those files from two different places — through the GitHub
contents/git APIs in properties-only mode, and against a real checkout in regenerate mode —
and the two must produce byte-identical results.
and the two must produce byte-identical results. Version comparison is delegated to
`version-cmp.js` (`cmp` here is a re-export) rather than duplicated.

It also carries a port of Dependabot's own wrapper-version resolution
(`maven/lib/dependabot/maven/file_parser/wrapper_mojo.rb`): `wrapperVersion`, then a version
Expand All @@ -93,6 +150,16 @@ repository's entire update job — no pull requests at all, not merely no wrappe
what the workflow's `check_only` mode predicts, and matching Dependabot's logic exactly is
why it can.

## `antora-ui-bundle.js`

The rules by which `update-antora-ui-bundle.yml` edits `docs/antora-playbook.yml`:
`PLAYBOOK_PATH`, `extractBundle(text)` (reads the `{ repo, tag, url }` of the UI bundle
release a playbook currently points at, matched by shape — a GitHub release asset download
link — rather than by position under `ui:`/`bundle:`/`url:`), and `rewrite(text, { repo, tag
})` (a textual, non-destructive URL swap — everything else in the file is preserved
byte-for-byte, the same contract `maven-wrapper-properties.js`'s `rewrite` gives for
`maven-wrapper.properties`). `cmp` is re-exported from `version-cmp.js` for tag comparison.

## Tests

```bash
Expand All @@ -101,10 +168,10 @@ npm install
npm test # or: npm run test:coverage
```

CI runs them in [test-maven-wrapper-properties.yml](../workflows/test-maven-wrapper-properties.yml),
which also extracts every inline `node` heredoc from `update-maven-wrapper.yml` and
syntax-checks it — a syntax error inside a YAML heredoc is otherwise invisible until the
workflow runs against a real repository.
CI runs them in [test-shared-scripts.yml](../workflows/test-shared-scripts.yml), which also
extracts every inline `node` heredoc from each consuming workflow and syntax-checks it — a
syntax error inside a YAML heredoc is otherwise invisible until the workflow runs against a
real repository.

Every module here is covered; `npm test` runs the lot. A workflow that `require`s one of
them **must check the repository out first** — these resolve under
Expand Down
63 changes: 63 additions & 0 deletions .github/scripts/__tests__/antora-ui-bundle.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const { PLAYBOOK_PATH, extractBundle, rewrite, cmp } = require('../antora-ui-bundle');

// The exact shape confirmed live on spring-cloud-commons's main, 4.2.x, 4.3.x and 5.0.x
// branches (and their -commercial counterparts).
const PLAYBOOK = [
'antora:',
' extensions:',
" - require: '@springio/antora-extensions'",
" root_component_name: 'cloud-commons'",
'site:',
' title: Spring Cloud Commons',
' url: https://docs.spring.io/spring-cloud-commons/reference/',
'content:',
' sources:',
' - url: ./..',
' branches: HEAD',
' start_path: docs',
' worktrees: true',
'ui:',
' bundle:',
' url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.15/ui-bundle.zip',
'',
].join('\n');

describe('PLAYBOOK_PATH', () => {
it('is the per-branch playbook path', () => {
expect(PLAYBOOK_PATH).toBe('docs/antora-playbook.yml');
});
});

describe('extractBundle', () => {
it('reads the repo, tag and full url from a real playbook', () => {
expect(extractBundle(PLAYBOOK)).toEqual({
repo: 'spring-io/antora-ui-spring',
tag: 'v0.4.15',
url: 'https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.15/ui-bundle.zip',
});
});

it('returns null when there is no release-download url at all', () => {
expect(extractBundle('ui:\n bundle:\n url: https://example.com/ui-bundle.zip\n')).toBeNull();
});
});

describe('rewrite', () => {
it('replaces only the tag, leaving everything else byte-identical', () => {
const out = rewrite(PLAYBOOK, { repo: 'spring-io/antora-ui-spring', tag: 'v0.4.26' });
expect(out).toContain(
'url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.26/ui-bundle.zip');
expect(out.replace('v0.4.26', 'v0.4.15')).toBe(PLAYBOOK);
});

it('is a no-op when the file has no bundle url', () => {
const noBundle = 'site:\n title: X\n';
expect(rewrite(noBundle, { repo: 'spring-io/antora-ui-spring', tag: 'v0.4.26' })).toBe(noBundle);
});
});

describe('cmp', () => {
it('is re-exported from version-cmp for tag comparison', () => {
expect(cmp('v0.4.26', 'v0.4.15')).toBeGreaterThan(0);
});
});
66 changes: 66 additions & 0 deletions .github/scripts/__tests__/merge-if-green.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const { classifyChecks } = require('../merge-if-green');

// Fixture shapes lifted from what `gh pr view --json statusCheckRollup` actually returns:
// a mix of legacy commit statuses (StatusContext) and modern check runs (CheckRun).
const checkRun = (name, status, conclusion) => ({ __typename: 'CheckRun', name, status, conclusion });
const statusContext = (context, state) => ({ __typename: 'StatusContext', context, state });

describe('classifyChecks', () => {
it('treats an empty rollup as no checks', () => {
const { checks, failing, pending } = classifyChecks([]);
expect(checks).toEqual([]);
expect(failing).toEqual([]);
expect(pending).toEqual([]);
});

it('treats undefined as no checks', () => {
expect(classifyChecks(undefined).checks).toEqual([]);
});

it('classifies a completed, successful check run as passing', () => {
const { failing, pending } = classifyChecks([checkRun('build', 'COMPLETED', 'SUCCESS')]);
expect(failing).toEqual([]);
expect(pending).toEqual([]);
});

it('classifies an in-progress check run as pending', () => {
const { pending } = classifyChecks([checkRun('build', 'IN_PROGRESS', null)]);
expect(pending.map(c => c.name)).toEqual(['build']);
});

it('classifies a failed check run as failing', () => {
const { failing } = classifyChecks([checkRun('build', 'COMPLETED', 'FAILURE')]);
expect(failing.map(c => c.name)).toEqual(['build']);
});

it('classifies NEUTRAL and SKIPPED conclusions as passing, not pending', () => {
const { failing, pending } = classifyChecks([
checkRun('optional', 'COMPLETED', 'NEUTRAL'),
checkRun('skipped-job', 'COMPLETED', 'SKIPPED'),
]);
expect(failing).toEqual([]);
expect(pending).toEqual([]);
});

it('classifies a legacy status context by its state', () => {
const { failing, pending } = classifyChecks([statusContext('ci/legacy', 'SUCCESS')]);
expect(failing).toEqual([]);
expect(pending).toEqual([]);
});

it('classifies a pending legacy status context as pending', () => {
const { pending } = classifyChecks([statusContext('ci/legacy', 'PENDING')]);
expect(pending.map(c => c.name)).toEqual(['ci/legacy']);
});

it('separates failing and pending checks in a mixed rollup', () => {
const { checks, failing, pending } = classifyChecks([
checkRun('build', 'COMPLETED', 'SUCCESS'),
checkRun('test', 'COMPLETED', 'FAILURE'),
checkRun('lint', 'IN_PROGRESS', null),
]);
expect(checks).toHaveLength(3);
expect(failing.map(c => c.name)).toEqual(['test']);
expect(pending.map(c => c.name)).toEqual(['lint']);
});
});
Loading
Loading