Merge hands/release-guard-env: the release guard runs every per-tag check in a cleaned environment
Suite, semver check and package tests run without NODE_OPTIONS, NODE_TEST_* or the publish token; tag/version match and package tests moved into the guard, so no step condition can skip them. Verdict cut-release-guard-env.s1 holds; its Low findings (threat model wording, shell ban, changelog heading) follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
082aec7962
4 changed files with 229 additions and 122 deletions
74
.github/workflows/publish-packages.yml
vendored
74
.github/workflows/publish-packages.yml
vendored
|
|
@ -10,8 +10,8 @@ name: Publish packages
|
|||
#
|
||||
# Tag a release to publish, for example `cultcache-ts-v0.13.6` or
|
||||
# `cultnet-py-v0.1.0`. The tag's version must match the version in the package
|
||||
# manifest; the workflow refuses the publish otherwise rather than shipping a
|
||||
# mislabelled artifact.
|
||||
# manifest; scripts/release-after-suite.mjs refuses the release otherwise rather
|
||||
# than shipping a mislabelled artifact.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# fetch-depth: 0 so the semver check below can see every previously
|
||||
# fetch-depth: 0 so the semver check in the release step can see every previously
|
||||
# published tag, not just the one that triggered this run.
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
|
|
@ -64,34 +64,20 @@ jobs:
|
|||
- name: Install workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify the tag matches the manifest version
|
||||
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
|
||||
run: |
|
||||
manifest="$(node -p "require('./packages/cultcache-ts/package.json').version")"
|
||||
tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
|
||||
if [ "$manifest" != "$tagged" ]; then
|
||||
echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# docs/semver-policy.md: a breaking change under too small a bump, or a
|
||||
# version that skips or reverses the previous release, fails here
|
||||
# instead of shipping with a number that lies.
|
||||
- name: Verify semver policy
|
||||
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
|
||||
run: |
|
||||
tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
|
||||
node scripts/check-changelog-semver.mjs --package "@gamecult/cultcache-ts" --version "$tagged"
|
||||
|
||||
# Manual runs publish nothing, so they build and test here. A tag run's
|
||||
# Publish runs these tests itself.
|
||||
- name: Build and test
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
working-directory: packages/cultcache-ts
|
||||
run: npm test
|
||||
|
||||
# Runs the release-checks suite, then npm publish --access public, in one
|
||||
# process that publishes only if the suite passed (docs/semver-policy.md).
|
||||
# One process checks that the tag is the manifest version, runs the
|
||||
# release-checks suite, the semver policy check (docs/semver-policy.md)
|
||||
# and npm test, and only then runs npm publish --access public. Only
|
||||
# npm publish receives the token.
|
||||
- name: Publish
|
||||
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
|
||||
run: node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts
|
||||
run: node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts "$GITHUB_REF"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
|
|
@ -123,7 +109,7 @@ jobs:
|
|||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# fetch-depth: 0 so the semver check below can see every previously
|
||||
# fetch-depth: 0 so the semver check in the release step can see every previously
|
||||
# published tag, not just the one that triggered this run.
|
||||
- name: Check out CultLib
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
|
|
@ -149,40 +135,20 @@ jobs:
|
|||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Verify the tag matches the manifest version
|
||||
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
manifest="$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('packages/${{ matrix.package }}/pyproject.toml').read_text())['project']['version'])")"
|
||||
tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
|
||||
if [ "$manifest" != "$tagged" ]; then
|
||||
echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# docs/semver-policy.md: a breaking change under too small a bump, or a
|
||||
# version that skips or reverses the previous release, fails here
|
||||
# instead of shipping with a number that lies.
|
||||
- name: Verify semver policy
|
||||
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
|
||||
node scripts/check-changelog-semver.mjs --package "${{ matrix.package }}" --version "$tagged"
|
||||
|
||||
# Lower layers install from source so a release of one package is tested
|
||||
# against the sibling revision it was cut with, not whatever PyPI has.
|
||||
- name: Install and test
|
||||
- name: Install
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
run: |
|
||||
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
|
||||
run: python -m pip install --upgrade build -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
|
||||
|
||||
# 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.
|
||||
# One process checks, on a tag, that the tag is the manifest version, runs
|
||||
# the release-checks suite, on a tag the semver policy check
|
||||
# (docs/semver-policy.md), and the package's unit tests, and only then
|
||||
# builds the sdist and wheel. Publish uploads only what this step built,
|
||||
# so it has nothing to upload unless every check passed.
|
||||
- name: Build sdist and wheel
|
||||
if: steps.selected.outputs.run == 'true'
|
||||
run: node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }}
|
||||
run: node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }} "$GITHUB_REF"
|
||||
|
||||
- name: Publish
|
||||
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
|
||||
|
|
|
|||
|
|
@ -271,8 +271,8 @@ scripts/check-changelog-semver.test.mjs`).
|
|||
|
||||
It is wired into:
|
||||
|
||||
- `.github/workflows/publish-packages.yml`, right after each job's existing
|
||||
"tag matches manifest version" check, for `cultcache-ts`, `cultcache-py`,
|
||||
- `.github/workflows/publish-packages.yml`, through
|
||||
`scripts/release-after-suite.mjs`, for `cultcache-ts`, `cultcache-py`,
|
||||
`cultnet-py`, and `cultmesh-py`, with `fetch-depth: 0` so the checkout holds
|
||||
the tags;
|
||||
- `scripts/build-unity-package.ps1`, for `org.gamecult.cultlib`;
|
||||
|
|
@ -282,16 +282,22 @@ It is wired into:
|
|||
|
||||
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.
|
||||
`publish-packages.yml` the release step runs `scripts/release-after-suite.mjs`
|
||||
with the run's git ref. From one process it checks that a tag ref is the
|
||||
package's declared prefix and manifest version, runs these tests, runs this
|
||||
check for the tagged version, runs the package's own tests (`npm test`, or the
|
||||
Python package's unit tests), and only then the release action: `npm publish`
|
||||
for `cultcache-ts`, which it refuses on any ref but a release tag, and for the
|
||||
Python packages the sdist and wheel build, whose output is all the PyPI upload
|
||||
step uploads. The checks run without `NODE_OPTIONS`, `NODE_TEST_CONTEXT`, any
|
||||
other `NODE_TEST_*` variable and `NODE_AUTH_TOKEN`, so no inherited variable can
|
||||
make the tests run nothing and pass; only the release action receives the token.
|
||||
The gate is that call, not a step condition, so no `if:` or `continue-on-error`
|
||||
can release what a check refused. The tests compare each job's release steps
|
||||
with their expected text exactly, less the steps' `if:` lines, and refuse `shell`
|
||||
and `defaults` anywhere in the workflow, which could run a release step's line
|
||||
through another command. 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
|
@ -742,23 +742,24 @@ test("workflow: the Caching Unity prefix is its own tag family, not another pack
|
|||
});
|
||||
|
||||
// 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.
|
||||
// publish-packages.yml releases only through scripts/release-after-suite.mjs, which runs the tag check, the
|
||||
// suite, the semver policy check and the package's tests and then the release action from one process, so no
|
||||
// if: or continue-on-error on any step can release what those checks 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 shell and defaults anywhere, which could
|
||||
// run the pinned line through another command. 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/;
|
||||
const code = (text) => text.split("\n").filter((line) => !line.trim().startsWith("#")).join("\n");
|
||||
|
||||
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 }}"],
|
||||
["- name: Publish", " run: node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts \"$GITHUB_REF\"", " 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: Build sdist and wheel", " run: node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }} \"$GITHUB_REF\""],
|
||||
["- name: Publish", " uses: pypa/gh-action-pypi-publish@release/v1", " with:", " packages-dir: packages/${{ matrix.package }}/dist"],
|
||||
],
|
||||
};
|
||||
|
|
@ -804,8 +805,9 @@ function workflowReleaseProblems(workflow) {
|
|||
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");
|
||||
// A job's or the workflow's defaults.run.shell would run the pinned release line through any command.
|
||||
if (/\bshell\b|\bdefaults\b/.test(code(workflow))) {
|
||||
problems.push("the workflow says shell or defaults: a release step must run its pinned line through the default shell");
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
|
@ -816,54 +818,95 @@ function scriptSuiteProblems(script) {
|
|||
return body.search(CHECKER) < body.search(SUITE) ? ["calls the checker before the suite"] : [];
|
||||
}
|
||||
|
||||
test("workflow: each job releases only through release-after-suite.mjs, and no step may fail without failing its job", () => {
|
||||
test("workflow: each job releases only through release-after-suite.mjs", () => {
|
||||
const workflow = workflowText();
|
||||
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 npmRun = 'run: node scripts/release-after-suite.mjs npm-publish packages/cultcache-ts "$GITHUB_REF"';
|
||||
const pythonRun = 'run: node scripts/release-after-suite.mjs python-build packages/${{ matrix.package }} "$GITHUB_REF"';
|
||||
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
|
||||
// anything else on the line, on a continuation line, or in a block; another call, ref or 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(edit(run.replace('"$GITHUB_REF"', "refs/tags/cultcache-ts-v0.14.0")), exact, run);
|
||||
assert.match(edit(`${run}\n shell: bash`), /another command|default shell/, run);
|
||||
assert.match(edit(`${run}\n continue-on-error: true`), exact, run);
|
||||
}
|
||||
// the guard runs the suite itself, so the release steps' conditions are free
|
||||
// the guard runs every release check itself, so neither the release steps' conditions nor a job allowed
|
||||
// to fail can release what a check refused
|
||||
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.deepEqual(workflowReleaseProblems(workflow.replace(/( runs-on: ubuntu-latest\r?\n)/, "$1 continue-on-error: true\n")), []);
|
||||
// a job's default shell, 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 defaults:\n run:\n shell: sh -c 'npm publish' {0}\n")).join("\n"), /default shell/);
|
||||
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;
|
||||
test("release-after-suite checks the tag, then runs the suite, the semver check and the package's tests, and releases only when all passed", async () => {
|
||||
const { releaseAfterSuite, suite, checker } = await import("./release-after-suite.mjs");
|
||||
const npmVersion = JSON.parse(readFileSync(join(repoRoot, "packages", "cultcache-ts", "package.json"), "utf8")).version;
|
||||
const npmDir = join(repoRoot, "packages", "cultcache-ts");
|
||||
const pyDir = join(repoRoot, "packages", "cultnet-py");
|
||||
const pyVersion = readFileSync(join(pyDir, "pyproject.toml"), "utf8").match(/^version = "([^"]+)"$/m)[1];
|
||||
const npmTag = `refs/tags/cultcache-ts-v${npmVersion}`;
|
||||
const pyTag = `refs/tags/cultnet-py-v${pyVersion}`;
|
||||
const env = { PATH: "p", NODE_AUTH_TOKEN: "t", NODE_OPTIONS: "--test-name-pattern=x", NODE_TEST_CONTEXT: "child", NODE_TEST_X: "1" };
|
||||
const checked = { PATH: "p" };
|
||||
let calls;
|
||||
// fail: the index of the call that exits 1
|
||||
const runner = (fail = -1) => (command, args, cwd, childEnv) => {
|
||||
calls.push([command, args, cwd, childEnv]);
|
||||
return calls.length - 1 === fail ? 1 : 0;
|
||||
};
|
||||
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);
|
||||
const go = (argv, fail) => {
|
||||
calls = [];
|
||||
return releaseAfterSuite(argv, runner(fail), env);
|
||||
};
|
||||
const suiteCall = [process.execPath, ["--test", suite], undefined, checked];
|
||||
const checkerCall = (name, version) => [process.execPath, [checker, "--package", name, "--version", version, "--cwd", repoRoot], undefined, checked];
|
||||
const npmCalls = [
|
||||
suiteCall,
|
||||
checkerCall("@gamecult/cultcache-ts", npmVersion),
|
||||
["npm", ["test"], npmDir, checked],
|
||||
["npm", ["publish", "--access", "public"], npmDir, env],
|
||||
];
|
||||
assert.equal(go(["npm-publish", npmDir, npmTag]), 0);
|
||||
assert.deepEqual(calls, npmCalls);
|
||||
// any check that fails stops the release at that check
|
||||
for (const fail of [0, 1, 2]) {
|
||||
assert.equal(go(["npm-publish", npmDir, npmTag], fail), 1, `check ${fail}`);
|
||||
assert.deepEqual(calls, npmCalls.slice(0, fail + 1), `check ${fail}`);
|
||||
}
|
||||
for (const argv of [[], ["npm-publish"], ["constructor", "x"], ["npm-publish", "x", "y"]]) {
|
||||
calls.length = 0;
|
||||
assert.equal(releaseAfterSuite(argv, runner(0)), 2, argv.join(" "));
|
||||
const pyCalls = [
|
||||
suiteCall,
|
||||
checkerCall("cultnet-py", pyVersion),
|
||||
["python", ["-m", "unittest", "discover", "-s", join(pyDir, "tests")], undefined, checked],
|
||||
["python", ["-m", "build", pyDir], undefined, env],
|
||||
];
|
||||
assert.equal(go(["python-build", pyDir, pyTag]), 0);
|
||||
assert.deepEqual(calls, pyCalls);
|
||||
// a branch ref builds Python without the tag's semver check, and never publishes npm
|
||||
assert.equal(go(["python-build", pyDir, "refs/heads/main"]), 0);
|
||||
assert.deepEqual(calls, [pyCalls[0], pyCalls[2], pyCalls[3]]);
|
||||
// a tag that is not the manifest version's, another package's tag, or no tag at all refuses before anything runs
|
||||
for (const argv of [
|
||||
["npm-publish", npmDir, `refs/tags/cultcache-ts-v${npmVersion}9`],
|
||||
["npm-publish", npmDir, `refs/tags/cultnet-py-v${npmVersion}`],
|
||||
["npm-publish", npmDir, "refs/heads/main"],
|
||||
["python-build", pyDir, `refs/tags/cultcache-py-v${pyVersion}`],
|
||||
["npm-publish", join(repoRoot, "packages"), npmTag],
|
||||
]) {
|
||||
assert.equal(go(argv), 1, argv.join(" "));
|
||||
assert.deepEqual(calls, [], argv.join(" "));
|
||||
}
|
||||
for (const argv of [[], ["npm-publish"], ["npm-publish", npmDir], ["constructor", "x", npmTag], ["npm-publish", npmDir, npmTag, "y"]]) {
|
||||
assert.equal(go(argv), 2, argv.join(" "));
|
||||
assert.deepEqual(calls, []);
|
||||
}
|
||||
// run as a script, a usage error exits 2 before the suite runs
|
||||
|
|
@ -871,6 +914,42 @@ test("release-after-suite runs the suite first, and the release action only when
|
|||
assert.throws(() => execFileSync(process.execPath, [script], { stdio: "pipe" }), (error) => error.status === 2);
|
||||
});
|
||||
|
||||
// Each of these makes `node --test` on a failing suite run nothing and exit 0 (Soul, asura cut-3-gate s4).
|
||||
// The guard must still refuse, and must never reach the publish.
|
||||
test("release-after-suite refuses when the environment would make the failing suite pass vacuously", async () => {
|
||||
const { releaseAfterSuite, suite } = await import("./release-after-suite.mjs");
|
||||
const dir = mkdtempSync(join(tmpdir(), "cultlib-release-guard-"));
|
||||
try {
|
||||
const failing = join(dir, "failing.test.mjs");
|
||||
writeFileSync(failing, 'import { test } from "node:test";\ntest("fails", () => { throw new Error("the suite failed"); });\n');
|
||||
const runTests = (env) => spawnSync(process.execPath, ["--test", failing], { env, stdio: "ignore" }).status ?? 1;
|
||||
const base = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^NODE_(OPTIONS|TEST_)/i.test(key)));
|
||||
assert.equal(runTests(base), 1, "the fixture suite fails");
|
||||
const npmDir = join(repoRoot, "packages", "cultcache-ts");
|
||||
const tag = `refs/tags/cultcache-ts-v${JSON.parse(readFileSync(join(npmDir, "package.json"), "utf8")).version}`;
|
||||
for (const bypass of [
|
||||
{ NODE_TEST_CONTEXT: "child" },
|
||||
{ NODE_TEST_CONTEXT: "child-v8" },
|
||||
{ NODE_OPTIONS: "--test-name-pattern=^zzz_nothing$" },
|
||||
{ NODE_OPTIONS: "--test-skip-pattern=." },
|
||||
]) {
|
||||
const env = { ...base, ...bypass, NODE_AUTH_TOKEN: "token" };
|
||||
const label = JSON.stringify(bypass);
|
||||
assert.equal(runTests(env), 0, `${label} no longer makes the failing suite pass, so this test proves nothing`);
|
||||
const released = [];
|
||||
const run = (command, args, cwd, childEnv) => {
|
||||
if (command === process.execPath && args.length === 2 && args[1] === suite) return runTests(childEnv);
|
||||
released.push([command, ...args]);
|
||||
return 0;
|
||||
};
|
||||
assert.equal(releaseAfterSuite(["npm-publish", npmDir, tag], run, env), 1, label);
|
||||
assert.deepEqual(released, [], label);
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the Unity release scripts run the suite before the checker; removing it fails this test", () => {
|
||||
const scripts = ["scripts/build-unity-package.ps1", "packages/cultmath/scripts/build-unity-package.ps1", "scripts/verify-caching-unity-release.ps1"];
|
||||
for (const script of scripts) {
|
||||
|
|
|
|||
|
|
@ -1,34 +1,90 @@
|
|||
// 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".
|
||||
// How publish-packages.yml releases a package. From this one process, in order: the release tag must name
|
||||
// the package's declared prefix and its manifest version, the release-checks suite must pass, the semver
|
||||
// policy check must pass for that version, the package's own tests must pass, and only then the release
|
||||
// action runs. No workflow condition can then release what any of those checks 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
|
||||
// node scripts/release-after-suite.mjs npm-publish <package dir> <git ref> npm publish --access public in <dir>
|
||||
// node scripts/release-after-suite.mjs python-build <package dir> <git ref> python -m build <dir>, for the PyPI step
|
||||
//
|
||||
// <git ref> is the run's GITHUB_REF. A tag ref is a release and gets the tag and semver checks; npm-publish
|
||||
// refuses any other ref. python-build on a branch ref builds without them, for the manual runs that publish
|
||||
// nothing.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import releasePackages from "./release-packages.mjs";
|
||||
|
||||
export const suite = fileURLToPath(new URL("./check-changelog-semver.test.mjs", import.meta.url));
|
||||
export const checker = fileURLToPath(new URL("./check-changelog-semver.mjs", import.meta.url));
|
||||
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
// The checks run without the variables that can make `node --test` run nothing and exit 0
|
||||
// (NODE_OPTIONS test filters, NODE_TEST_CONTEXT, any NODE_TEST_*), and without the publish token,
|
||||
// which only the release action receives.
|
||||
const UNCHECKED = /^(NODE_OPTIONS|NODE_TEST_.*|NODE_AUTH_TOKEN)$/i;
|
||||
export const checkEnv = (env) => Object.fromEntries(Object.entries(env).filter(([key]) => !UNCHECKED.test(key)));
|
||||
|
||||
const pyprojectField = (text, field) =>
|
||||
text.split(/^\[/m).find((section) => section.startsWith("project]"))?.match(new RegExp(`^${field} = "([^"]+)"$`, "m"))?.[1];
|
||||
|
||||
// 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],
|
||||
"npm-publish": {
|
||||
manifest: (dir) => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")),
|
||||
test: (dir) => ["npm", ["test"], dir],
|
||||
release: (dir) => ["npm", ["publish", "--access", "public"], dir],
|
||||
tagOnly: true,
|
||||
},
|
||||
"python-build": {
|
||||
manifest: (dir) => {
|
||||
const text = readFileSync(join(dir, "pyproject.toml"), "utf8");
|
||||
return { name: pyprojectField(text, "name"), version: pyprojectField(text, "version") };
|
||||
},
|
||||
test: (dir) => ["python", ["-m", "unittest", "discover", "-s", join(dir, "tests")], undefined],
|
||||
release: (dir) => ["python", ["-m", "build", dir], undefined],
|
||||
tagOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const spawn = (command, args, cwd) => spawnSync(command, args, { cwd, stdio: "inherit" }).status ?? 1;
|
||||
const spawn = (command, args, cwd, env) => spawnSync(command, args, { cwd, env, 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>`);
|
||||
const refuse = (message) => {
|
||||
console.error(`${message}; not releasing.`);
|
||||
return 1;
|
||||
};
|
||||
|
||||
// Returns the exit code. `run(command, args, cwd, env)` returns a command's exit code; tests pass their own.
|
||||
export function releaseAfterSuite(argv, run = spawn, env = process.env) {
|
||||
const [name, dir, ref, ...rest] = argv;
|
||||
if (!Object.hasOwn(actions, name ?? "") || !dir || !ref || rest.length > 0) {
|
||||
console.error(`usage: node scripts/release-after-suite.mjs <${Object.keys(actions).join("|")}> <package dir> <git ref>`);
|
||||
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;
|
||||
const action = actions[name];
|
||||
let manifest;
|
||||
try {
|
||||
manifest = action.manifest(dir);
|
||||
} catch {
|
||||
return refuse(`${dir} has no readable manifest`);
|
||||
}
|
||||
return run(...actions[action](dir));
|
||||
const entry = Object.hasOwn(releasePackages, manifest.name ?? "") ? releasePackages[manifest.name] : null;
|
||||
if (entry === null) return refuse(`${dir} is ${manifest.name}, which scripts/release-packages.mjs does not declare`);
|
||||
const isTag = ref.startsWith("refs/tags/");
|
||||
const tag = `refs/tags/${entry.tagPrefix}-v${manifest.version}`;
|
||||
if (isTag && ref !== tag) return refuse(`${ref} is not ${tag}, the tag of ${manifest.name}'s manifest version`);
|
||||
if (!isTag && action.tagOnly) return refuse(`${name} releases only from a release tag, and ${ref} is not one`);
|
||||
|
||||
const clean = checkEnv(env);
|
||||
if (run(process.execPath, ["--test", suite], undefined, clean) !== 0) {
|
||||
return refuse("The release check suite failed (scripts/check-changelog-semver.test.mjs)");
|
||||
}
|
||||
if (isTag && run(process.execPath, [checker, "--package", manifest.name, "--version", manifest.version, "--cwd", repoRoot], undefined, clean) !== 0) {
|
||||
return refuse(`${manifest.name} ${manifest.version} fails the semver policy (docs/semver-policy.md)`);
|
||||
}
|
||||
if (run(...action.test(dir), clean) !== 0) return refuse(`${manifest.name}'s own tests failed`);
|
||||
return run(...action.release(dir), env);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue