The `Dry-run publish validation` step ran `npm publish --dry-run` with
no `if:` guard. `npm publish --dry-run` contacts the registry and
exits 1 with "You cannot publish over the previously published
versions" when the target version exists.
The earlier `Detect prior publish (reconciliation mode)` step already
discovers this case and sets steps.prior_publish.outputs.skip_publish=true.
The actual publish step (further down) is gated on that. The
rehearsal step was missing the gate, so any re-run of an
already-published hotfix blew up at the rehearsal before reaching
the reconciliation logic — exactly when an operator is trying to
recover from a later-step failure (merge-back, summary, etc.).
Add `if: ${{ steps.prior_publish.outputs.skip_publish != 'true' }}`
matching the publish step's gate. The rehearsal still runs on first
publishes where it has value.
Trigger: run 25233855236.
Closes #2987
141 lines
6.1 KiB
JavaScript
141 lines
6.1 KiB
JavaScript
/**
|
|
* Regression test for bug #2987
|
|
*
|
|
* The release-sdk workflow's `Dry-run publish validation` step ran
|
|
* `npm publish --dry-run --tag "$TAG"` unconditionally. `npm publish
|
|
* --dry-run` contacts the registry and exits 1 when the version is
|
|
* already published:
|
|
*
|
|
* npm error You cannot publish over the previously published
|
|
* versions: 1.39.1.
|
|
*
|
|
* The earlier `Detect prior publish (reconciliation mode)` step
|
|
* already detects this case and sets
|
|
* `steps.prior_publish.outputs.skip_publish=true` — and the real
|
|
* publish step at line ~648 is gated on that. The dry-run validation
|
|
* was missing the same gate, so re-runs of an already-published
|
|
* hotfix (the operator's typical recovery path when a later step
|
|
* like merge-back fails) blew up at the rehearsal before reaching
|
|
* any of the reconciliation logic.
|
|
*
|
|
* Trigger run: 25233855236 — re-attempted v1.39.1 hotfix after the
|
|
* prior run had landed v1.39.1 on npm.
|
|
*
|
|
* Fix: gate the dry-run validation step on
|
|
* `steps.prior_publish.outputs.skip_publish != 'true'`, matching the
|
|
* publish step.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// allow-test-rule: source-text-is-the-product
|
|
// release-sdk.yml IS the product for hotfix automation; the assertions
|
|
// extract the workflow text and check the step-level `if:` guard via
|
|
// indentation-aware YAML parsing rather than raw-text grep.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'release-sdk.yml');
|
|
|
|
/**
|
|
* Find a step by name and return the lines belonging to it (from the
|
|
* `- name:` line up to but not including the next `- name:` at the
|
|
* same indent or the next dedent-back-to-job).
|
|
*/
|
|
function extractStepBlock(workflowText, stepName) {
|
|
const lines = workflowText.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const m = lines[i].match(/^(\s*)- name:\s*(.+?)\s*$/);
|
|
if (!m || m[2] !== stepName) continue;
|
|
const stepIndent = m[1].length;
|
|
const start = i;
|
|
let end = lines.length;
|
|
for (let j = i + 1; j < lines.length; j++) {
|
|
const peek = lines[j];
|
|
if (peek.length === 0) continue;
|
|
const lead = peek.match(/^(\s*)/)[1].length;
|
|
// Next sibling step or dedent past step indent terminates this block.
|
|
if (lead <= stepIndent && peek.trim().length > 0) {
|
|
if (/^\s*- /.test(peek) || lead < stepIndent) {
|
|
end = j;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return lines.slice(start, end).join('\n');
|
|
}
|
|
throw new Error(`step "${stepName}" not found in workflow`);
|
|
}
|
|
|
|
describe('bug-2987: dry-run publish validation skips when reconciliation mode is active', () => {
|
|
test('Dry-run publish validation step has an `if:` guard tied to skip_publish', () => {
|
|
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const block = extractStepBlock(yaml, 'Dry-run publish validation');
|
|
|
|
// The guard must reference steps.prior_publish.outputs.skip_publish
|
|
// — the exact output set by the `Detect prior publish` step.
|
|
// Loosely accepting any boolean expression here would risk a future
|
|
// edit that gates on the wrong signal (e.g., inputs.dry_run, which
|
|
// is the user-facing dry-run flag, not registry reconciliation).
|
|
assert.match(
|
|
block,
|
|
/^\s*if:\s*\$\{\{\s*steps\.prior_publish\.outputs\.skip_publish\s*!=\s*'true'\s*\}\}\s*$/m,
|
|
"Dry-run publish validation must be gated on `steps.prior_publish.outputs.skip_publish != 'true'` so reconciliation re-runs (version already on npm) don't fail at the rehearsal (#2987)"
|
|
);
|
|
});
|
|
|
|
test('the gate matches the actual publish step\'s gate (consistency with downstream skip)', () => {
|
|
// The publish step ("Publish to npm (CC bundle, ...)" further
|
|
// down) ALSO honors skip_publish. The rehearsal must honor it too;
|
|
// otherwise reconciliation runs always fail at the rehearsal.
|
|
// This test reads both gates and asserts the skip_publish
|
|
// sub-expression is identical between them. It allows the publish
|
|
// step to ALSO check inputs.dry_run (which it does, and which the
|
|
// rehearsal correctly does NOT — the rehearsal is the dry-run).
|
|
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const dryRunBlock = extractStepBlock(yaml, 'Dry-run publish validation');
|
|
const publishBlock = extractStepBlock(yaml, 'Publish to npm (CC bundle, SDK included as both loose tree and .tgz)');
|
|
|
|
const skipPattern = /steps\.prior_publish\.outputs\.skip_publish\s*!=\s*'true'/;
|
|
assert.match(
|
|
dryRunBlock,
|
|
skipPattern,
|
|
'Dry-run validation must check skip_publish (#2987)'
|
|
);
|
|
assert.match(
|
|
publishBlock,
|
|
skipPattern,
|
|
'Publish step must check skip_publish (sentinel — if this fails the workflow has changed and the test\'s premise needs review)'
|
|
);
|
|
});
|
|
|
|
test('the workflow still runs the rehearsal in normal flows (gate is skip-only, not always-skip)', () => {
|
|
// Defense against the wrong fix: someone could pass-through-fix
|
|
// this by gating on `false` (always skip) which would silently
|
|
// disable the rehearsal even on first publishes. The gate must
|
|
// be specifically tied to the skip_publish signal, not a generic
|
|
// `false` or `inputs.action == 'something'` discriminator.
|
|
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
|
const block = extractStepBlock(yaml, 'Dry-run publish validation');
|
|
|
|
// The gate string itself must contain a comparison against 'true' —
|
|
// i.e., it's an opt-out for the prior-publish case, not an
|
|
// unconditional skip.
|
|
const ifLine = block.split('\n').find((l) => /^\s*if:/.test(l));
|
|
assert.ok(ifLine, 'Dry-run validation must have an `if:` line (#2987)');
|
|
assert.match(
|
|
ifLine,
|
|
/skip_publish\s*!=\s*'true'/,
|
|
'gate must be `skip_publish != true` (run when not skipping), not an unconditional skip — the rehearsal still has value on first publishes (#2987)'
|
|
);
|
|
assert.doesNotMatch(
|
|
ifLine,
|
|
/:\s*false\s*\}\}/,
|
|
'gate must not be `if: false` — the rehearsal is meaningful when the version isn\'t yet on npm (#2987)'
|
|
);
|
|
});
|
|
});
|