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
15 changes: 12 additions & 3 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,18 @@ jobs:
git diff --quiet
test -z "$(git ls-files --others --exclude-standard)"
verify_prepared_tree
git commit -m "chore(release): prepare beta from $SOURCE_SHA [skip release]"
test -z "$(git status --porcelain)"
git push origin "HEAD:refs/heads/release/beta-$SHA_PREFIX"
if ! git commit -m "chore(release): prepare beta from $SOURCE_SHA [skip release]"; then
echo "::error::PREPARE local commit failed"
exit 1
fi
if ! test -z "$(git status --porcelain)"; then
echo "::error::PREPARE post-commit tree dirty"
exit 1
fi
if ! git push origin "HEAD:refs/heads/release/beta-$SHA_PREFIX"; then
echo "::error::PREPARE release-branch push failed"
exit 1
fi
VERSIONS=$(while IFS=$'\t' read -r project name manifest; do printf '%s=%s\n' "$name" "$(jq -er '.version' "$manifest")"; done < "$RECORDS" | paste -sd, -)
CHANGED_PATHS=$(paste -sd, /tmp/expected-release-paths)
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
Expand Down
75 changes: 72 additions & 3 deletions scripts/release-policy-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"
import { spawnSync } from "node:child_process"
import { readFileSync } from "node:fs"
import test from "node:test"

Expand Down Expand Up @@ -175,6 +176,18 @@
new RegExp(`^pnpm nx release publish "--projects=\\$PROJECTS" --tag=${channel}$`)
const betaVersionCommand =
/^pnpm nx release version "--projects=\$PROJECTS" --preid=beta --git-commit=false --git-tag=false --git-push=false --stage-changes=false$/
const terminalGates = [
{
command: 'git commit -m "chore(release): prepare beta from $SOURCE_SHA [skip release]"',
annotation: "PREPARE local commit failed",
},
{ command: 'test -z "$(git status --porcelain)"', annotation: "PREPARE post-commit tree dirty" },
{
command: 'git push origin "HEAD:refs/heads/release/beta-$SHA_PREFIX"',
annotation: "PREPARE release-branch push failed",
},
]
const exactCommand = (value) => new RegExp(`^${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)
const buildCommand = /^pnpm nx run-many -t build "--projects=\$PROJECTS" --parallel=3$/
const testCommand = /^pnpm nx run-many -t test "--projects=\$PROJECTS" --parallel=3 --passWithNoTests$/
const contractCommand = /^node --test scripts\/release-policy-contract\.test\.mjs$/
Expand Down Expand Up @@ -292,8 +305,8 @@
violations.push("beta PREPARE step")
} else {
const commands = prepare.commands.join("\n")
const pushes = prepare.commands.filter((command) => /^git push\b/.test(command))
if (pushes.length !== 1 || !/^git push origin "HEAD:refs\/heads\/release\/beta-\$SHA_PREFIX"$/.test(pushes[0])) {
const pushes = prepare.commands.filter((command) => /^(?:if ! )?git push\b/.test(command))
if (pushes.length !== 1 || !exactCommand(`if ! ${terminalGates[2].command}; then`).test(pushes[0])) {
violations.push("beta PREPARE sole branch push")
}
for (const [pattern, name] of [
Expand All @@ -313,12 +326,21 @@
"safe staged-path annotation",
],
[/^'@effectify\/solid-query=0\.5\.12-beta\.0' \| sort > "\$EXPECTED_MATRIX"$/, "sorted incident matrix"],
[/^git commit -m "chore\(release\): prepare beta from \$SOURCE_SHA \[skip release\]"$/, "release commit"],
[exactCommand(`if ! ${terminalGates[0].command}; then`), "release commit"],
]) {
if (!prepare.commands.some((command) => pattern.test(command))) violations.push(`beta PREPARE ${name}`)
}
if ((commands.match(/verify_prepared_tree/g) ?? []).length < 3)
violations.push("beta PREPARE repeated verification")
for (const gate of terminalGates) {
const sequence = [
exactCommand(`if ! ${gate.command}; then`),
exactCommand(`echo "::error::${gate.annotation}"`),
/^exit 1$/,
/^fi$/,
]
if (!hasCommandSequence(prepare.commands, sequence)) violations.push(`beta PREPARE ${gate.annotation}`)
}
if (/\bmapfile\b|^git add -- "\$\{RELEASE_PATHS\[@\]\}"$/m.test(commands)) {
violations.push("beta PREPARE array staging")
}
Expand Down Expand Up @@ -498,10 +520,10 @@

const releasePolicyBootstrapViolations = (source) => {
const steps = extractSteps(extractJob(source, "release-policy"))
const setupNodeIndex = steps.findIndex((step) => /^actions\/setup-node@/.test(step.uses))

Check warning on line 523 in scripts/release-policy-contract.test.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

unicorn(prefer-string-starts-ends-with)

Prefer String#startsWith over a regex with a caret.
if (setupNodeIndex === -1) return ["release-policy setup-node"]

const pnpmIndex = steps.findIndex((step) => /^pnpm\/action-setup@/.test(step.uses))

Check warning on line 526 in scripts/release-policy-contract.test.mjs

View workflow job for this annotation

GitHub Actions / 🔍 Lint & Format

unicorn(prefer-string-starts-ends-with)

Prefer String#startsWith over a regex with a caret.
const cacheDisabled = steps[setupNodeIndex].packageManagerCache === "false"
return pnpmIndex !== -1 && pnpmIndex < setupNodeIndex ? [] : cacheDisabled ? [] : ["release-policy setup-node cache"]
}
Expand Down Expand Up @@ -625,6 +647,53 @@
assert.match(setup, /sole `type:\*` label is `type:chore`/)
})

test("beta PREPARE terminal gates and annotations fail closed under mutation", () => {
const policy = { ...workflows, docs: readme }
for (const gate of terminalGates) {
const block = `if ! ${gate.command}; then\n echo "::error::${gate.annotation}"\n exit 1\n fi`
assertMutationFails(`remove ${gate.annotation} guard`, policy, (candidate) => ({
...candidate,
beta: mutate(candidate.beta, block, ` ${gate.command}`),
}))
assertMutationFails(`remove ${gate.annotation} annotation`, policy, (candidate) => ({
...candidate,
beta: mutate(candidate.beta, `::error::${gate.annotation}`, "::error::removed"),
}))
}
})

test("beta PREPARE terminal gates emit only fixed diagnostics and stop later commands", () => {
const prepare = extractSteps(workflows.beta).find((step) =>
step.commands.some((command) => betaVersionCommand.test(command)),
)
assert.ok(prepare)
const start = prepare.commands.indexOf(`if ! ${terminalGates[0].command}; then`)
const last = prepare.commands.indexOf(`if ! ${terminalGates.at(-1).command}; then`)
const end = prepare.commands.indexOf("fi", last)
assert.ok(start >= 0 && last > start && end > last)
const block = prepare.commands.slice(start, end + 1).join("\n")
const run = (failure) =>
spawnSync(
"bash",
[
"-c",
`SOURCE_SHA=abc SHA_PREFIX=abc\ngit() {\n case "$1" in\n commit) [ "$FAILURE" != commit ] || return 1 ;;\n status) [ "$FAILURE" != commit ] || echo "LATE status"; [ "$FAILURE" != status ] || printf dirty ;;\n push) [ "$FAILURE" != commit ] || echo "LATE push"; [ "$FAILURE" != status ] || echo "LATE push"; [ "$FAILURE" != push ] || return 1 ;;\n esac\n}\n${block}\necho SENTINEL`,
],
{ encoding: "utf8", env: { FAILURE: failure } },
)

for (const [index, gate] of terminalGates.entries()) {
const result = run(["commit", "status", "push"][index])
assert.equal(result.status, 1)
assert.equal(result.stdout, `::error::${gate.annotation}\n`)
assert.equal(result.stderr, "")
}
const success = run("none")
assert.equal(success.status, 0)
assert.equal(success.stdout, "SENTINEL\n")
assert.equal(success.stderr, "")
})

test("beta PREPARE and suppression mutations fail closed", () => {
const policy = { ...workflows, docs: readme }

Expand Down
Loading