Release gate: publish-packages.yml releases through one entrypoint that runs the suite first; the if: pin is gone
scripts/release-after-suite.mjs runs the release-checks suite and then the release action (npm publish, or the Python sdist and wheel build that the PyPI step uploads) from one process, and releases only if the suite passed. No step condition can release what the suite did not pass, so the pin that read if: conditions (status functions, multi-line if:, the leading-conjunct rule) is deleted with its tests and docs. What remains compares each job's release steps with their expected text exactly, less their if: line, and refuses continue-on-error, shell and defaults in the workflow. Ruling asura:ruling:suite-gate-structural-not-parsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
303476db6c
commit
bc4af4fa58
4 changed files with 163 additions and 150 deletions
29
.github/workflows/publish-packages.yml
vendored
29
.github/workflows/publish-packages.yml
vendored
|
|
@ -55,17 +55,12 @@ jobs:
|
|||
node-version: 24.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
# The suite pins the release checker's rules and the declared tag prefixes
|
||||
# (docs/semver-policy.md). It runs before any check or publish step, on a
|
||||
# checkout that holds every tag.
|
||||
# The release-checks suite, which Publish runs, needs .NET for the API gate.
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Test the release checks
|
||||
run: node --test scripts/check-changelog-semver.test.mjs
|
||||
|
||||
- name: Install workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
|
|
@ -92,12 +87,11 @@ jobs:
|
|||
working-directory: packages/cultcache-ts
|
||||
run: npm test
|
||||
|
||||
# Scoped packages default to restricted even on public npm, and this is
|
||||
# MIT like the rest of the repo, so say public explicitly.
|
||||
# Runs the release-checks suite, then npm publish --access public, in one
|
||||
# process that publishes only if the suite passed (docs/semver-policy.md).
|
||||
- name: Publish
|
||||
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
|
||||
working-directory: packages/cultcache-ts
|
||||
run: npm publish --access public
|
||||
run: node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
|
|
@ -155,11 +149,6 @@ jobs:
|
|||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
# See the same step in the npm job: the suite comes before any check or publish step.
|
||||
- name: Test the release checks
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
run: node --test scripts/check-changelog-semver.test.mjs
|
||||
|
||||
- name: Verify the tag matches the manifest version
|
||||
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
|
|
@ -184,14 +173,16 @@ jobs:
|
|||
- name: Install and test
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
run: |
|
||||
python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
|
||||
python -m pip install --upgrade build -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
|
||||
python -m unittest discover -s packages/${{ matrix.package }}/tests
|
||||
|
||||
# Runs the release-checks suite, then builds the sdist and wheel, in one
|
||||
# process that builds only if the suite passed (docs/semver-policy.md).
|
||||
# Publish uploads only what this step built, so it has nothing to upload
|
||||
# unless the suite passed.
|
||||
- name: Build sdist and wheel
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade build
|
||||
python -m build packages/${{ matrix.package }}
|
||||
run: node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }}
|
||||
|
||||
- name: Publish
|
||||
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ or a missing declaration refuses), and:
|
|||
workflow tag trigger must be a declared prefix, and each such package's prefix
|
||||
must be a workflow trigger or, for the Caching Unity package the workflow does
|
||||
not build, the prefix of tags that hold its own manifest at the tag's own
|
||||
version, so a typo or a copied prefix fails the suite. The workflow and the
|
||||
three Unity release scripts run the suite before any check or publish step.
|
||||
version, so a typo or a copied prefix fails the suite. Nothing releases
|
||||
without the suite passing; see below for how each release path runs it.
|
||||
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
|
||||
recorded `partial-tags-first-release`.
|
||||
Tags that cannot be read (a `--cwd` that is not a git repository, or a tag
|
||||
|
|
@ -280,20 +280,18 @@ It is wired into:
|
|||
`org.gamecult.cultmath`;
|
||||
- `scripts/verify-caching-unity-release.ps1`, for `org.gamecult.caching.unity`.
|
||||
|
||||
Each of those runs the check's own tests first, and the tests pin that order.
|
||||
In `publish-packages.yml` the test step must run whenever the job's Publish
|
||||
step does. The pin proves that from the text of the two `if:` conditions, so it
|
||||
accepts only three shapes: no `if:` on the test step, the same `if:` on both,
|
||||
or Publish's `if:` as the test step's followed by ` && ` with no `||`. The pin
|
||||
reads one line, so a multi-line `if:` is refused: a block scalar (`>-`, `|`),
|
||||
or a plain or quoted value continued on the next line. So is a Publish `if:`
|
||||
calling `always()`, `cancelled()`, `failure()` or `success()`: any status
|
||||
function replaces the implicit `success()`, so `success() || c` would publish
|
||||
after the tests failed, and `success() && c` says nothing `c` alone does not. Matching text rather than parsing GitHub's expression language also
|
||||
refuses some sound conditions: a parenthesised `S && (c || d)`, conjuncts in
|
||||
another order, and a condition wrapped in `${{ }}` or spaced differently on one
|
||||
step only. Rewrite those into an accepted shape; a wrong pass would publish a
|
||||
package the tests never checked.
|
||||
Each of those runs the check's own tests before it releases. The Unity release
|
||||
scripts run them before they call the check, and the tests pin that order. In
|
||||
`publish-packages.yml` the release step runs `scripts/release-after-suite.mjs`,
|
||||
which runs the tests and then the release action from one process, and releases
|
||||
only if the tests passed: `npm publish` for `cultcache-ts`, and for the Python
|
||||
packages the sdist and wheel build, whose output is all the PyPI upload step
|
||||
uploads. The gate is that call, not a step condition, so no `if:` can release
|
||||
what the tests did not pass. The tests compare each job's release steps with
|
||||
their expected text exactly, less the steps' `if:` lines, and refuse
|
||||
`continue-on-error`, `shell` and `defaults` anywhere in the workflow. Removing
|
||||
the call is still possible, but only as a visible edit to those steps and to the
|
||||
tests.
|
||||
|
||||
`org.gamecult.cultlib` compiles CultMath from source and ships beside
|
||||
`org.gamecult.cultmath`, so its build refuses unless it declares exactly the
|
||||
|
|
|
|||
|
|
@ -741,79 +741,72 @@ test("workflow: the Caching Unity prefix is its own tag family, not another pack
|
|||
assert.match(prefixPinProblems(releasePackages, workflowText(), [], readAtTag).join("\n"), /are the tags fetched\?/);
|
||||
});
|
||||
|
||||
// The suite is what pins the declared prefixes and the checker's rules, so the places that release
|
||||
// run it first: every job of publish-packages.yml, on a checkout that holds every tag, with .NET set
|
||||
// up for the API gate, before any step that checks or publishes; and each Unity release script,
|
||||
// before it calls the checker.
|
||||
// The suite is what pins the declared prefixes and the checker's rules, so nothing releases without it.
|
||||
// publish-packages.yml releases only through scripts/release-after-suite.mjs, which runs the suite and then
|
||||
// the release action from one process, so no if: on any step can release what the suite did not pass. This
|
||||
// pin reads no condition. It compares each job's release steps whole, less their own if: line, with the
|
||||
// text below, so a release step that does anything else is a mismatch, and it refuses any step or job that
|
||||
// may fail without failing the job, or that runs through another shell. Each Unity release script runs the
|
||||
// suite itself, before it calls the checker.
|
||||
const SUITE = /node\s+--test[^\n]*check-changelog-semver\.test\.mjs/;
|
||||
const CHECKER = /check-changelog-semver\.mjs/;
|
||||
// Any status function replaces a step's implicit success(), so Publish could run after the suite failed:
|
||||
// the same bypass as continue-on-error. success() is refused too: "success() || c" publishes on c alone,
|
||||
// and "success() && c" is sound but says nothing the same condition without it does not. "!cancelled()"
|
||||
// matches through "cancelled(".
|
||||
const STATUS_FUNCTION = /\b(?:always|cancelled|failure|success)\s*\(/;
|
||||
const code = (text) => text.split("\n").filter((line) => !line.trim().startsWith("#")).join("\n");
|
||||
|
||||
// The if: of the step that holds offset `at` in a job's text, or null when the step has none. A step
|
||||
// runs from its "- name:" line to the next one. Only the if: line is read, so an if: whose value goes on
|
||||
// past that line comes back as MULTI_LINE, which workflowSuiteProblems refuses: a block scalar (if: >-,
|
||||
// if: |), or a plain or quoted scalar continued on a following line indented deeper than the key, where
|
||||
// always() or "|| true" would hide from a one-line read.
|
||||
const MULTI_LINE = Symbol("multi-line if:");
|
||||
function stepCondition(job, at) {
|
||||
const names = [...job.matchAll(/^\s+- name:/gm)].map((match) => match.index);
|
||||
const start = names.filter((index) => index <= at).at(-1) ?? 0;
|
||||
const end = names.find((index) => index > at) ?? job.length;
|
||||
const step = job.slice(start, end);
|
||||
const condition = step.match(/^([ \t]*)if:[ \t]*(.*?)[ \t]*\r?$/m);
|
||||
if (condition === null) return null;
|
||||
const next = step.slice(condition.index + condition[0].length).split("\n").find((line) => line.trim() !== "");
|
||||
const continued = next !== undefined && next.match(/^[ \t]*/)[0].length > condition[1].length;
|
||||
return continued || /^[>|]/.test(condition[2]) ? MULTI_LINE : condition[2];
|
||||
const RELEASE_STEPS = {
|
||||
"cultcache-ts": [
|
||||
["- name: Publish", " run: node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts", " env:", " NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}"],
|
||||
],
|
||||
// Publish uploads only the dist the guarded step built, so it has nothing to upload unless the suite passed.
|
||||
python: [
|
||||
["- name: Build sdist and wheel", " run: node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }}"],
|
||||
["- name: Publish", " uses: pypa/gh-action-pypi-publish@release/v1", " with:", " packages-dir: packages/${{ matrix.package }}/dist"],
|
||||
],
|
||||
};
|
||||
|
||||
// A job's steps, each as its lines relative to its "- name:" line, without comments, blank lines or the
|
||||
// step's own one-line if:. A line indented less than the step stays whole, so it cannot match.
|
||||
function jobSteps(job) {
|
||||
const steps = [];
|
||||
for (const line of job.split(/\r?\n/)) {
|
||||
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
||||
const name = line.match(/^( *)- name:/);
|
||||
if (name) steps.push({ indent: name[1], lines: [] });
|
||||
const step = steps.at(-1);
|
||||
if (step === undefined) continue;
|
||||
const relative = line.startsWith(step.indent) ? line.slice(step.indent.length) : line;
|
||||
if (!/^ if: .*$/.test(relative)) step.lines.push(relative);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
// The suite must run whenever Publish does, so Publish's condition has to imply the suite's. Three
|
||||
// shapes prove that textually: the suite step has no if:, the two conditions are equal, or Publish's
|
||||
// is the suite's followed by " && ". The last is sound only when Publish's condition holds no "||":
|
||||
// && binds tighter than ||, so "S && c || d" publishes on d alone, and "a || b && c" splits S itself.
|
||||
// Matching text rather than parsing GitHub's expression language refuses some sound conditions too:
|
||||
// a parenthesised "S && (c || d)", conjuncts in another order ("c && S"), and a condition wrapped in
|
||||
// ${{ }} or spaced differently on one step only. Such a refusal costs a rewrite into one of the three
|
||||
// shapes; a wrong pass would publish a package the suite never checked.
|
||||
function suiteConditionCovers(suite, publish) {
|
||||
if (suite === null || suite === publish) return true;
|
||||
return publish !== null && publish.startsWith(`${suite} && `) && !publish.includes("||");
|
||||
}
|
||||
|
||||
function workflowSuiteProblems(workflow) {
|
||||
const [, jobs] = workflow.split(/^jobs:\r?\n/m);
|
||||
function workflowReleaseProblems(workflow) {
|
||||
const [, jobs = ""] = workflow.split(/^jobs:\r?\n/m);
|
||||
const starts = [...jobs.matchAll(/^ ([\w-]+):\r?\n/gm)];
|
||||
const problems = [];
|
||||
if (starts.length === 0) problems.push("the workflow has no jobs");
|
||||
for (const id of Object.keys(RELEASE_STEPS)) {
|
||||
if (!starts.some((start) => start[1] === id)) problems.push(`the workflow has no job ${id}`);
|
||||
}
|
||||
starts.forEach((start, index) => {
|
||||
const job = code(jobs.slice(start.index, starts[index + 1]?.index ?? jobs.length));
|
||||
const at = (pattern) => job.search(pattern);
|
||||
const suite = at(SUITE);
|
||||
const label = `job ${start[1]}`;
|
||||
if (suite < 0) return problems.push(`${label} does not run the suite`);
|
||||
if (/continue-on-error/.test(job)) problems.push(`${label} lets a step fail without failing the job`);
|
||||
if (at(/fetch-depth:\s*0\b/) < 0 || at(/fetch-depth:\s*0\b/) > suite) problems.push(`${label} runs the suite before checking out with fetch-depth: 0`);
|
||||
if (at(/actions\/setup-dotnet@/) < 0 || at(/actions\/setup-dotnet@/) > suite) problems.push(`${label} runs the suite before setting up .NET`);
|
||||
for (const [what, pattern] of [["the checker", CHECKER], ["a publish step", /^\s+- name: Publish\b/m]]) {
|
||||
if (at(pattern) < 0) problems.push(`${label} has no ${what}`);
|
||||
else if (at(pattern) < suite) problems.push(`${label} reaches ${what} before the suite`);
|
||||
}
|
||||
const publish = at(/^\s+- name: Publish\b/m);
|
||||
if (publish < 0) return;
|
||||
const [suiteIf, publishIf] = [stepCondition(job, suite), stepCondition(job, publish)];
|
||||
if ([suiteIf, publishIf].includes(MULTI_LINE)) {
|
||||
problems.push(`${label} gives the suite or Publish a multi-line if: (a block scalar, or a value continued on the next line), which the pin does not read; write it on one line`);
|
||||
} else if (STATUS_FUNCTION.test(publishIf ?? "")) {
|
||||
problems.push(`${label} lets Publish run after the suite fails or is skipped: its if: calls always(), cancelled(), failure() or success()`);
|
||||
} else if (!suiteConditionCovers(suiteIf, publishIf)) {
|
||||
problems.push(`${label} runs the suite under a condition the pin cannot prove Publish implies; it accepts no suite if:, an equal if:, or Publish's if: as the suite's followed by " && " with no "||"`);
|
||||
if (!Object.hasOwn(RELEASE_STEPS, start[1])) return problems.push(`${label} is not a job this pin knows; give it its release steps in RELEASE_STEPS`);
|
||||
const steps = jobSteps(jobs.slice(start.index, starts[index + 1]?.index ?? jobs.length));
|
||||
let from = 0;
|
||||
for (const want of RELEASE_STEPS[start[1]]) {
|
||||
const named = steps.filter((step) => step.lines[0] === want[0]);
|
||||
const at = steps.findIndex((step, i) => i >= from && step.lines[0] === want[0]);
|
||||
if (named.length !== 1 || at < 0) {
|
||||
problems.push(`${label} needs exactly one step "${want[0]}", after the release step before it`);
|
||||
continue;
|
||||
}
|
||||
if (steps[at].lines.join("\n") !== want.join("\n")) {
|
||||
problems.push(`${label} step "${want[0]}" is not exactly the release call:\n${steps[at].lines.join("\n")}`);
|
||||
}
|
||||
from = at + 1;
|
||||
}
|
||||
});
|
||||
if (/continue-on-error|\bshell\b|\bdefaults\b/.test(code(workflow))) {
|
||||
problems.push("the workflow says continue-on-error, shell or defaults: no step may fail without failing its job, or run through another shell");
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
|
|
@ -823,64 +816,59 @@ function scriptSuiteProblems(script) {
|
|||
return body.search(CHECKER) < body.search(SUITE) ? ["calls the checker before the suite"] : [];
|
||||
}
|
||||
|
||||
test("workflow: every job runs the suite first, with every tag fetched; removing, moving or disabling the step fails this test", () => {
|
||||
test("workflow: each job releases only through release-after-suite.mjs, and no step may fail without failing its job", () => {
|
||||
const workflow = workflowText();
|
||||
assert.deepEqual(workflowSuiteProblems(workflow), []);
|
||||
const suiteLines = /^\s+run: node --test scripts\/check-changelog-semver\.test\.mjs\r?\n/gm;
|
||||
assert.equal(workflow.match(suiteLines).length, 2);
|
||||
// the step removed from either job, or from both
|
||||
let removed = 0;
|
||||
assert.deepEqual(workflowSuiteProblems(workflow.replace(suiteLines, (line) => (removed++ === 0 ? " run: true\n" : line))).length, 1);
|
||||
assert.equal(workflowSuiteProblems(workflow.replace(suiteLines, " run: true\n")).length, 2);
|
||||
// the step moved after the checker, a shallow checkout, no .NET, and a step allowed to fail
|
||||
const moved = workflow
|
||||
.replace(suiteLines, " run: true\n")
|
||||
.replace(/(\s+- name: Verify semver policy\b)/, "\n - name: Late\n run: node --test scripts/check-changelog-semver.test.mjs$1");
|
||||
assert.notDeepEqual(workflowSuiteProblems(moved), []);
|
||||
assert.notDeepEqual(workflowSuiteProblems(workflow.replace(/fetch-depth: 0/g, "fetch-depth: 1")), []);
|
||||
assert.notDeepEqual(workflowSuiteProblems(workflow.replace(/actions\/setup-dotnet@/g, "actions/setup-other@")), []);
|
||||
assert.notDeepEqual(workflowSuiteProblems(workflow.replace(suiteLines, (line) => `${line} continue-on-error: true\n`)), []);
|
||||
// the suite step disabled, or run under a condition Publish does not imply
|
||||
const npmSuiteIf = (condition) => {
|
||||
let seen = 0;
|
||||
return workflow.replace(suiteLines, (line) => (seen++ === 0 ? ` if: ${condition}\n${line}` : line));
|
||||
};
|
||||
const pythonSuiteIf = (condition) =>
|
||||
workflow.replace(/if: steps\.selected\.outputs\.run == 'true'(\r?\n\s+run: node --test scripts\/check-changelog-semver\.test\.mjs)/, `if: ${condition}$1`);
|
||||
const narrower = /runs the suite under a condition the pin cannot prove Publish implies; it accepts no suite if:, an equal if:/;
|
||||
assert.match(workflowSuiteProblems(npmSuiteIf("false")).join("\n"), narrower);
|
||||
assert.match(workflowSuiteProblems(pythonSuiteIf("false")).join("\n"), narrower);
|
||||
assert.match(workflowSuiteProblems(npmSuiteIf("github.event_name == 'workflow_dispatch'")).join("\n"), narrower);
|
||||
// a textual prefix that is not a whole conjunct proves nothing
|
||||
assert.match(workflowSuiteProblems(pythonSuiteIf("steps.selected.outputs.run")).join("\n"), narrower);
|
||||
// a leading conjunct is not enough once Publish's condition has an ||
|
||||
const pythonPublishOr = workflow.replace(/(- name: Publish\r?\n\s+if: steps\.selected\.outputs\.run == 'true' && startsWith\(github\.ref, 'refs\/tags\/'\))/, "$1 || true");
|
||||
assert.match(workflowSuiteProblems(pythonPublishOr).join("\n"), narrower);
|
||||
// a sound condition outside the three shapes is refused too, and the message says why
|
||||
const pythonPublishIf = (rewrite) =>
|
||||
workflow.replace(/(- name: Publish\r?\n\s+if: )(steps\.selected\.outputs\.run == 'true' && startsWith\(github\.ref, 'refs\/tags\/'\))/, (_, head, condition) => `${head}${rewrite(condition)}`);
|
||||
assert.match(workflowSuiteProblems(pythonPublishIf((condition) => condition.replace("startsWith(github.ref, 'refs/tags/')", "(startsWith(github.ref, 'refs/tags/') || false)"))).join("\n"), narrower);
|
||||
// a status function in Publish's if: runs it after the suite failed or was skipped
|
||||
const npmPublishIf = (prefix) => workflow.replace(/(- name: Publish\r?\n\s+if: )(startsWith\(github\.ref, 'refs\/tags\/cultcache-ts-v'\))/, `$1${prefix}$2`);
|
||||
const status = /lets Publish run after the suite fails or is skipped/;
|
||||
for (const prefix of ["always() && ", "!cancelled() && ", "cancelled() || ", "failure() && ", "success() || "]) {
|
||||
assert.match(workflowSuiteProblems(npmPublishIf(prefix)).join("\n"), status, prefix);
|
||||
assert.deepEqual(workflowReleaseProblems(workflow), []);
|
||||
const npmRun = "run: node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts";
|
||||
const pythonRun = "run: node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }}";
|
||||
const exact = /is not exactly the release call/;
|
||||
for (const run of [npmRun, pythonRun]) {
|
||||
assert.equal(workflow.split(run).length, 2, run);
|
||||
const edit = (replacement) => workflowReleaseProblems(workflow.replace(run, replacement)).join("\n");
|
||||
// anything else on the line, on a continuation line, or in a block; another call; another shell
|
||||
assert.match(edit(`${run} || true`), exact, run);
|
||||
assert.match(edit(`${run}\n || true`), exact, run);
|
||||
assert.match(edit("run: |\n node --test scripts/check-changelog-semver.test.mjs\n true"), exact, run);
|
||||
assert.match(edit(run.replace("release-after-suite.mjs", "check-changelog-semver.mjs")), exact, run);
|
||||
assert.match(edit(`${run}\n shell: bash`), /another shell/, run);
|
||||
assert.match(edit(`${run}\n continue-on-error: true`), /no step may fail/, run);
|
||||
}
|
||||
assert.match(workflowSuiteProblems(pythonPublishIf((condition) => condition.replace(" && ", " && always() && "))).join("\n"), status);
|
||||
// a multi-line if: on either step is refused rather than read by its first line: a block scalar...
|
||||
const multiLine = /gives the suite or Publish a multi-line if:/;
|
||||
const npmBlockSuite = npmSuiteIf(">-\n false");
|
||||
assert.match(workflowSuiteProblems(npmBlockSuite.replace(/(- name: Publish\r?\n\s+if: )(startsWith\(github\.ref, 'refs\/tags\/cultcache-ts-v'\))/, "$1>-\n $2")).join("\n"), multiLine);
|
||||
assert.match(workflowSuiteProblems(npmSuiteIf("|\n true")).join("\n"), multiLine);
|
||||
assert.match(workflowSuiteProblems(npmPublishIf(">-\n ")).join("\n"), multiLine);
|
||||
// ...or a plain or quoted scalar continued on a following line, where always() or "|| true" would hide
|
||||
const npmPublishTail = (tail) => workflow.replace(/(- name: Publish\r?\n\s+if: startsWith\(github\.ref, 'refs\/tags\/cultcache-ts-v'\))/, `$1${tail}`);
|
||||
assert.match(workflowSuiteProblems(npmPublishTail(" &&\n always()")).join("\n"), multiLine);
|
||||
assert.match(workflowSuiteProblems(pythonPublishIf((condition) => `${condition}\n || true`)).join("\n"), multiLine);
|
||||
assert.match(workflowSuiteProblems(npmPublishIf("\"").replace(/(if: "startsWith\(github\.ref, 'refs\/tags\/cultcache-ts-v'\))/, "$1 &&\n always()\"")).join("\n"), multiLine);
|
||||
assert.match(workflowSuiteProblems(npmSuiteIf("startsWith(github.ref,\n 'refs/tags/cultcache-ts-v')")).join("\n"), multiLine);
|
||||
// a suite condition equal to Publish's passes
|
||||
assert.deepEqual(workflowSuiteProblems(npmSuiteIf("startsWith(github.ref, 'refs/tags/cultcache-ts-v')")), []);
|
||||
// the guard runs the suite itself, so the release steps' conditions are free
|
||||
assert.deepEqual(workflowReleaseProblems(workflow.replace(/if: startsWith\(github\.ref, 'refs\/tags\/cultcache-ts-v'\)(\r?\n\s+run: node)/, "if: always()$1")), []);
|
||||
// a job allowed to fail, a release step removed, renamed or doubled, a Publish that uploads another dist, an unknown job
|
||||
assert.match(workflowReleaseProblems(workflow.replace(/( runs-on: ubuntu-latest\r?\n)/, "$1 continue-on-error: true\n")).join("\n"), /no step may fail/);
|
||||
assert.match(workflowReleaseProblems(workflow.replace("- name: Build sdist and wheel", "- name: Build")).join("\n"), /needs exactly one step "- name: Build sdist and wheel"/);
|
||||
assert.match(workflowReleaseProblems(workflow.replace("- name: Build and test", "- name: Publish\n run: npm publish\n\n - name: Build and test")).join("\n"), /needs exactly one step "- name: Publish"/);
|
||||
assert.match(workflowReleaseProblems(workflow.replace("packages-dir: packages/${{ matrix.package }}/dist", "packages-dir: dist")).join("\n"), exact);
|
||||
assert.match(workflowReleaseProblems(`${workflow}\n other:\n runs-on: ubuntu-latest\n`).join("\n"), /job other is not a job this pin knows/);
|
||||
});
|
||||
|
||||
test("release-after-suite runs the suite first, and the release action only when the suite passed", async () => {
|
||||
const { releaseAfterSuite, suite } = await import("./release-after-suite.mjs");
|
||||
const calls = [];
|
||||
const runner = (suiteStatus, actionStatus = 0) => (command, args, cwd) => {
|
||||
calls.push([command, args, cwd]);
|
||||
return command === process.execPath ? suiteStatus : actionStatus;
|
||||
};
|
||||
const suiteCall = [process.execPath, ["--test", suite], undefined];
|
||||
assert.equal(releaseAfterSuite(["npm-publish", "packages/cultcache-ts"], runner(0)), 0);
|
||||
assert.deepEqual(calls, [suiteCall, ["npm", ["publish", "--access", "public"], "packages/cultcache-ts"]]);
|
||||
calls.length = 0;
|
||||
assert.equal(releaseAfterSuite(["python-build", "packages/cultnet-py"], runner(0, 3)), 3);
|
||||
assert.deepEqual(calls, [suiteCall, ["python", ["-m", "build", "packages/cultnet-py"], undefined]]);
|
||||
for (const action of ["npm-publish", "python-build"]) {
|
||||
calls.length = 0;
|
||||
assert.equal(releaseAfterSuite([action, "packages/x"], runner(1)), 1);
|
||||
assert.deepEqual(calls, [suiteCall], action);
|
||||
}
|
||||
for (const argv of [[], ["npm-publish"], ["constructor", "x"], ["npm-publish", "x", "y"]]) {
|
||||
calls.length = 0;
|
||||
assert.equal(releaseAfterSuite(argv, runner(0)), 2, argv.join(" "));
|
||||
assert.deepEqual(calls, []);
|
||||
}
|
||||
// run as a script, a usage error exits 2 before the suite runs
|
||||
const script = fileURLToPath(new URL("./release-after-suite.mjs", import.meta.url));
|
||||
assert.throws(() => execFileSync(process.execPath, [script], { stdio: "pipe" }), (error) => error.status === 2);
|
||||
});
|
||||
|
||||
test("the Unity release scripts run the suite before the checker; removing it fails this test", () => {
|
||||
|
|
|
|||
36
scripts/release-after-suite.mjs
Normal file
36
scripts/release-after-suite.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// How publish-packages.yml releases a package: the release-checks suite runs first, and the release
|
||||
// action runs only if the suite passed, both from this one process. No workflow condition can then
|
||||
// release what the suite did not pass. docs/semver-policy.md, "The suite gates every release".
|
||||
//
|
||||
// node scripts/release-after-suite.mjs npm-publish <package dir> npm publish --access public in <dir>
|
||||
// node scripts/release-after-suite.mjs python-build <package dir> python -m build <dir>, for the PyPI step
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
export const suite = fileURLToPath(new URL("./check-changelog-semver.test.mjs", import.meta.url));
|
||||
|
||||
// Scoped npm packages default to restricted, and CultLib is MIT, so the publish says public.
|
||||
export const actions = {
|
||||
"npm-publish": (dir) => ["npm", ["publish", "--access", "public"], dir],
|
||||
"python-build": (dir) => ["python", ["-m", "build", dir], undefined],
|
||||
};
|
||||
|
||||
const spawn = (command, args, cwd) => spawnSync(command, args, { cwd, stdio: "inherit" }).status ?? 1;
|
||||
|
||||
// Returns the exit code. `run(command, args, cwd)` returns a command's exit code; tests pass their own.
|
||||
export function releaseAfterSuite(argv, run = spawn) {
|
||||
const [action, dir, ...rest] = argv;
|
||||
if (!Object.hasOwn(actions, action ?? "") || !dir || rest.length > 0) {
|
||||
console.error(`usage: node scripts/release-after-suite.mjs <${Object.keys(actions).join("|")}> <package dir>`);
|
||||
return 2;
|
||||
}
|
||||
if (run(process.execPath, ["--test", suite], undefined) !== 0) {
|
||||
console.error("The release check suite failed (scripts/check-changelog-semver.test.mjs); not releasing.");
|
||||
return 1;
|
||||
}
|
||||
return run(...actions[action](dir));
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exitCode = releaseAfterSuite(process.argv.slice(2));
|
||||
}
|
||||
Loading…
Reference in a new issue