diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50ce67462..f59be8139 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,13 @@ on: # the cheap branch-wide `CI-refs/heads/main` group meant a later main push # silently cancelled the in-flight release and left the tag without a GitHub # Release or npm publish (#2564). Detect release runs at workflow-scheduling -# time via the release-script commit subject (`chore: bump version to vX.Y.Z`, -# see scripts/release.ts) and via `v*` tag-ref dispatches, then scope them to -# a per-sha group with no cancellation. Every other event keeps the -# branch-wide cancel-in-progress for PR/main churn. +# time via the release-script commit subject (`chore: bump version to vX.Y.Z`), +# via `v*` tag-ref dispatches, and via manual dispatches whose tag-on-HEAD +# status is only known after checkout; scope them to a per-sha group with no +# cancellation. Every other event keeps branch-wide cancellation for PR/main churn. concurrency: - group: "${{ github.workflow }}-${{ (startsWith(github.event.head_commit.message, 'chore: bump version to ') || startsWith(github.ref, 'refs/tags/v')) && format('release-{0}', github.sha) || github.ref }}" - cancel-in-progress: "${{ !(startsWith(github.event.head_commit.message, 'chore: bump version to ') || startsWith(github.ref, 'refs/tags/v')) }}" + group: "${{ github.workflow }}-${{ (startsWith(github.event.head_commit.message, 'chore: bump version to ') || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && format('release-{0}', github.sha) || github.ref }}" + cancel-in-progress: "${{ !(startsWith(github.event.head_commit.message, 'chore: bump version to ') || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') }}" env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true diff --git a/scripts/ci-concurrency.test.ts b/scripts/ci-concurrency.test.ts index ebfe3226b..0311e0d1d 100644 --- a/scripts/ci-concurrency.test.ts +++ b/scripts/ci-concurrency.test.ts @@ -26,8 +26,8 @@ interface GhaCtx { } // Single-purpose, hand-rolled evaluator for the operators / functions the -// workflow's `concurrency` block uses: `startsWith`, `format`, `!`, `&&`, `||`, -// parens, single-quoted strings, dotted property access. Matches GitHub's +// workflow's `concurrency` block uses: `startsWith`, `format`, `!`, `==`, +// `&&`, `||`, parens, single-quoted strings, dotted property access. Matches // short-circuit semantics: `&&`/`||` return the underlying value (not a coerced // bool), missing identifiers resolve to `null`, and `startsWith(null, …)` is // false because the searchString coerces to `""`. @@ -73,30 +73,47 @@ class GhaEval { let left = this.#and(); while (this.#consume("||")) { const right = this.#and(); - // Truthy left wins; only null/false/""/0 fall through. - if (left !== null && left !== false && left !== "" && left !== 0) continue; + // Truthy left wins; only null/false/"" fall through. + if (left !== null && left !== false && left !== "") continue; left = right; } return left; } #and(): Value { - let left = this.#unary(); + let left = this.#eq(); while (this.#consume("&&")) { - const right = this.#unary(); + const right = this.#eq(); // Falsy left short-circuits and is returned verbatim. - if (left === null || left === false || left === "" || left === 0) continue; + if (left === null || left === false || left === "") continue; left = right; } return left; } + #eq(): Value { + let left = this.#unary(); + while (true) { + if (this.#consume("==")) { + const right = this.#unary(); + left = left === right; + continue; + } + if (this.#consume("!=")) { + const right = this.#unary(); + left = left !== right; + continue; + } + return left; + } + } + #unary(): Value { this.#skipWs(); if (this.src[this.#pos] === "!") { this.#pos++; const v = this.#unary(); - return v === null || v === false || v === "" || v === 0; + return v === null || v === false || v === ""; } return this.#primary(); } @@ -270,6 +287,16 @@ describe("ci.yml concurrency", () => { expect(GhaEval.template(cancelTemplate, ctx)).toBe("false"); }); + it("workflow_dispatch from tagged main HEAD is isolated before release_metadata can inspect tags", () => { + const ctx = baseCtx({ + event_name: "workflow_dispatch", + sha: "taggedmain123", + event: {}, + }); + expect(GhaEval.template(groupTemplate, ctx)).toBe("CI-release-taggedmain123"); + expect(GhaEval.template(cancelTemplate, ctx)).toBe("false"); + }); + it("regular main push: branch-wide group, cancel-in-progress enabled", () => { const ctx = baseCtx({ event: { head_commit: { message: "fix(ux): theme tweak" } } }); expect(GhaEval.template(groupTemplate, ctx)).toBe("CI-refs/heads/main");