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
2 changes: 2 additions & 0 deletions .github/scripts/pr-automation-workflow.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ describe("PR automation workflow contract", () => {
assert.match(source, /!pr\.merged && pr\.head\?\.sha === run\.head_sha/);
assert.match(source, /context\.eventName !== "workflow_run"/);
assert.match(source, /\[409, 422\]/);
assert.match(source, /isMissingPullRequestError\(error\)/);
assert.match(source, /linked pull request.*no longer exists/);
assert.doesNotMatch(source, /reRunWorkflow\s*\(/);
});

Expand Down
5 changes: 5 additions & 0 deletions .github/scripts/pr-automation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ function workflowRunRetryDisposition({ run, pr, repository } = {}) {
return { action: "rerun", reason: `first-${run.conclusion}`, runId: Number(run.id), pullNumber: Number(pr.number) };
}

function isMissingPullRequestError(error) {
return Number(error?.status) === 404;
}

function filePathEntries(files = []) {
return (files || []).flatMap(file => {
if (typeof file === "string") return [{ filename: file }];
Expand Down Expand Up @@ -441,6 +445,7 @@ module.exports = {
buildAutomationComment,
classifyPullRequest,
exactHeadGate,
isMissingPullRequestError,
summarizeAgedHolds,
workflowRunRetryDisposition,
};
10 changes: 10 additions & 0 deletions .github/scripts/pr-automation.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
buildAutomationComment,
classifyPullRequest,
exactHeadGate,
isMissingPullRequestError,
REQUIRED_CHECKS,
summarizeAgedHolds,
workflowRunRetryDisposition,
Expand Down Expand Up @@ -134,6 +135,15 @@ describe("workflowRunRetryDisposition", () => {
});
});

describe("isMissingPullRequestError", () => {
it("recognizes only GitHub's missing-resource status", () => {
assert.equal(isMissingPullRequestError({ status: 404 }), true);
assert.equal(isMissingPullRequestError({ status: "404" }), true);
assert.equal(isMissingPullRequestError({ status: 403 }), false);
assert.equal(isMissingPullRequestError(new Error("not found")), false);
});
});

function passingGateInput(overrides = {}) {
return {
liveHeadSha: SHA,
Expand Down
53 changes: 53 additions & 0 deletions .github/scripts/release-postpublish.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use strict";

const SHA_RE = /^[0-9a-f]{40}$/i;

function decideReleasePostpublish({
expectedSha,
npmExists,
npmGitHead,
tagSha,
releaseExists,
dryRun,
}) {
if (!SHA_RE.test(String(expectedSha || ""))) {
throw new Error("expected release SHA must be a full commit SHA");
}
if (npmExists && !SHA_RE.test(String(npmGitHead || ""))) {
throw new Error("published npm version has no trustworthy gitHead");
}
if (npmExists && String(npmGitHead).toLowerCase() !== String(expectedSha).toLowerCase()) {
throw new Error("published npm version belongs to a different commit");
}
if (tagSha && !SHA_RE.test(String(tagSha))) {
throw new Error("release tag did not resolve to a commit");
}
if (tagSha && String(tagSha).toLowerCase() !== String(expectedSha).toLowerCase()) {
throw new Error("release tag belongs to a different commit");
}
if (releaseExists && !tagSha) {
throw new Error("GitHub Release exists without its verified tag");
}

if (dryRun) {
return {
action: "dry-run",
publish: false,
createTag: false,
createRelease: false,
};
}

if (!npmExists && (tagSha || releaseExists)) {
throw new Error("Git metadata already exists before npm publication");
}

return {
action: npmExists ? "resume" : "publish",
publish: !npmExists,
createTag: !tagSha,
createRelease: !releaseExists,
};
}

module.exports = { decideReleasePostpublish };
63 changes: 63 additions & 0 deletions .github/scripts/release-postpublish.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use strict";

const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { decideReleasePostpublish } = require("./release-postpublish.cjs");

const SHA = "0123456789abcdef0123456789abcdef01234567";
const OTHER_SHA = "89abcdef0123456789abcdef0123456789abcdef";

function decide(overrides = {}) {
return decideReleasePostpublish({
expectedSha: SHA,
npmExists: false,
npmGitHead: "",
tagSha: "",
releaseExists: false,
dryRun: false,
...overrides,
});
}

describe("release post-publish recovery", () => {
it("starts a new release only when all public metadata is absent", () => {
assert.deepEqual(decide(), {
action: "publish",
publish: true,
createTag: true,
createRelease: true,
});
});

it("resumes an npm-success/tag-failure run at the exact package gitHead", () => {
assert.deepEqual(decide({ npmExists: true, npmGitHead: SHA }), {
action: "resume",
publish: false,
createTag: true,
createRelease: true,
});
});

it("makes a completed release idempotent", () => {
assert.deepEqual(decide({
npmExists: true,
npmGitHead: SHA,
tagSha: SHA,
releaseExists: true,
}), {
action: "resume",
publish: false,
createTag: false,
createRelease: false,
});
});

it("fails closed on mismatched or incomplete provenance", () => {
assert.throws(() => decide({ expectedSha: "short" }), /full commit SHA/);
assert.throws(() => decide({ npmExists: true, npmGitHead: OTHER_SHA }), /different commit/);
assert.throws(() => decide({ npmExists: true, npmGitHead: "" }), /trustworthy gitHead/);
assert.throws(() => decide({ tagSha: OTHER_SHA }), /different commit/);
assert.throws(() => decide({ releaseExists: true }), /without its verified tag/);
assert.throws(() => decide({ tagSha: SHA }), /before npm publication/);
});
});
17 changes: 10 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@ jobs:
# green again. Keep the leg sharded and bounded: removing it from ordinary
# pushes is what allowed the Windows-only backlog to accumulate unnoticed.
platform-windows:
name: windows ${{ matrix.shard }}/4
name: windows ${{ matrix.shard }}/6
needs: [changes]
if: >-
(github.event_name != 'pull_request' && github.event_name != 'merge_group') ||
Expand All @@ -656,11 +656,14 @@ jobs:
# margin. 25 leaves the outer bound in place — a wedged shard still dies —
# while making a completed shard the normal outcome. The crash-retry below can
# double a shard's work, and this ceiling has to cover that second attempt too.
# Four shards later grew back into that ceiling as the suite expanded. Six
# shards reduce each hosted-Windows process's filesystem, Worker, and child
# process pressure without raising the ceiling or weakening assertions.
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
shard: [1, 2, 3, 4, 5, 6]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
Expand Down Expand Up @@ -722,11 +725,11 @@ jobs:
fi
general_list="$(mktemp -t ocx-windows-general.XXXXXX)"
serial_list="$(mktemp -t ocx-windows-serial.XXXXXX)"
if ! bun scripts/ci/test-lanes.ts --lane general "${timing_args[@]}" --shard ${{ matrix.shard }}/4 > "$general_list"; then
if ! bun scripts/ci/test-lanes.ts --lane general "${timing_args[@]}" --shard ${{ matrix.shard }}/6 > "$general_list"; then
echo "::error::Windows general lane selection failed."
exit 1
fi
if ! bun scripts/ci/test-lanes.ts --lane serial "${timing_args[@]}" --shard ${{ matrix.shard }}/4 > "$serial_list"; then
if ! bun scripts/ci/test-lanes.ts --lane serial "${timing_args[@]}" --shard ${{ matrix.shard }}/6 > "$serial_list"; then
echo "::error::Windows serial lane selection failed."
exit 1
fi
Expand Down Expand Up @@ -766,12 +769,12 @@ jobs:
exit 0
fi
if ! grep -Eqi 'oh no: Bun has crashed|Internal assertion failure|Segmentation fault at address|Illegal instruction|Bus error|Aborted \(core dumped\)' "$suite_log"; then
echo "::error::Windows shard ${{ matrix.shard }}/4 failed on attempt ${attempt} (exit ${suite_status}); assertion failures are not retried."
echo "::error::Windows shard ${{ matrix.shard }}/6 failed on attempt ${attempt} (exit ${suite_status}); assertion failures are not retried."
exit "$suite_status"
fi
echo "::warning::Bun runtime crash in Windows shard ${{ matrix.shard }}/4 (exit ${suite_status}, attempt ${attempt})."
echo "::warning::Bun runtime crash in Windows shard ${{ matrix.shard }}/6 (exit ${suite_status}, attempt ${attempt})."
done
echo "::error::Bun runtime crash repeated on Windows shard ${{ matrix.shard }}/4; failing after one retry."
echo "::error::Bun runtime crash repeated on Windows shard ${{ matrix.shard }}/6; failing after one retry."
exit 1

- name: CLI help smoke
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/pr-automation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ jobs:
buildAutomationComment,
classifyPullRequest,
exactHeadGate,
isMissingPullRequestError,
summarizeAgedHolds,
workflowRunRetryDisposition,
} = require(path.join(process.cwd(), ".github", "scripts", "pr-automation.cjs"));
Expand Down Expand Up @@ -144,7 +145,14 @@ jobs:
})).filter(pr => !pr.merged && pr.head?.sha === run.head_sha);
}
if (candidates.length !== 1) return;
const pr = await getPr(Number(candidates[0].number));
let pr;
try {
pr = await getPr(Number(candidates[0].number));
} catch (error) {
if (!isMissingPullRequestError(error)) throw error;
core.info(`CI rerun skipped: linked pull request #${candidates[0].number} no longer exists.`);
return;
}
const disposition = workflowRunRetryDisposition({
run, pr, repository: `${owner}/${repo}`,
});
Expand Down
97 changes: 63 additions & 34 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ jobs:
# PREREQUISITE: configure the Trusted Publisher for this repo + workflow on npmjs.com — possible
# only AFTER the package's first version exists (do the first publish locally, see the runbook).
- name: Preflight release metadata
id: release-metadata
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ env.DISPATCH_VERSION }}
Expand All @@ -371,37 +372,41 @@ jobs:
git fetch --force --tags origin

existing_tag_sha="$(git rev-parse -q --verify "refs/tags/${release_tag}^{commit}" || true)"
if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" != "$GITHUB_SHA" ]; then
echo "::error::${release_tag} already points at ${existing_tag_sha}, not ${GITHUB_SHA}"
exit 1
fi

if [ -n "$existing_tag_sha" ]; then
if [ "$dry_run" = "true" ]; then
echo "::notice::${release_tag} already exists at this commit; dry-run only"
else
echo "::error::${release_tag} already exists. Refusing to publish a version with pre-existing Git metadata."
exit 1
fi
release_exists=false
gh release view "$release_tag" >/dev/null 2>&1 && release_exists=true

npm_exists=false
npm_git_head=""
if npm_metadata="$(npm view "${pkg_name}@${RELEASE_VERSION}" version gitHead --json 2>/dev/null)"; then
npm_exists=true
npm_git_head="$(jq -r '.gitHead // empty' <<<"$npm_metadata")"
fi

if gh release view "$release_tag" >/dev/null 2>&1; then
if [ "$dry_run" = "true" ]; then
echo "::notice::GitHub Release ${release_tag} already exists; dry-run only"
else
echo "::error::GitHub Release ${release_tag} already exists. Choose the next unused patch version."
exit 1
fi
fi

if npm view "${pkg_name}@${RELEASE_VERSION}" version >/dev/null 2>&1; then
if [ "$dry_run" = "true" ]; then
echo "::notice::${pkg_name}@${RELEASE_VERSION} already exists on npm; dry-run only"
else
echo "::error::${pkg_name}@${RELEASE_VERSION} already exists on npm. Choose the next unused patch version."
exit 1
fi
fi
decision="$(
EXPECTED_SHA="$GITHUB_SHA" \
NPM_EXISTS="$npm_exists" \
NPM_GIT_HEAD="$npm_git_head" \
TAG_SHA="$existing_tag_sha" \
RELEASE_EXISTS="$release_exists" \
RELEASE_DRY_RUN="$dry_run" \
node - <<'NODE'
const { decideReleasePostpublish } = require("./.github/scripts/release-postpublish.cjs");
const result = decideReleasePostpublish({
expectedSha: process.env.EXPECTED_SHA,
npmExists: process.env.NPM_EXISTS === "true",
npmGitHead: process.env.NPM_GIT_HEAD,
tagSha: process.env.TAG_SHA,
releaseExists: process.env.RELEASE_EXISTS === "true",
dryRun: process.env.RELEASE_DRY_RUN === "true",
});
process.stdout.write(JSON.stringify(result));
NODE
)"
action="$(jq -r .action <<<"$decision")"
echo "publish-needed=$(jq -r .publish <<<"$decision")" >> "$GITHUB_OUTPUT"
echo "tag-needed=$(jq -r .createTag <<<"$decision")" >> "$GITHUB_OUTPUT"
echo "release-needed=$(jq -r .createRelease <<<"$decision")" >> "$GITHUB_OUTPUT"
echo "::notice::Release metadata disposition: ${action}"

- name: Build and validate release changelog
env:
Expand All @@ -427,6 +432,7 @@ jobs:
DRY_RUN: ${{ env.DISPATCH_DRY_RUN }}
NPM_DIST_TAG: ${{ env.DISPATCH_TAG }}
CANDIDATE_PACKAGE_PATH: ${{ steps.candidate-package.outputs.path }}
PUBLISH_NEEDED: ${{ steps.release-metadata.outputs.publish-needed }}
run: |
set -euo pipefail
package_file=""
Expand All @@ -438,6 +444,8 @@ jobs:
echo "::notice::TRANSITIONAL DRY RUN — building locally; automatic main releases always consume an immutable candidate"
npm run prepublishOnly
npm pack --dry-run
elif [ "$PUBLISH_NEEDED" != "true" ]; then
echo "::notice::Exact npm version is already published from ${GITHUB_SHA}; resuming post-publish metadata only"
elif [ -z "$DISPATCH_CANDIDATE_RUN_ID" ]; then
echo "::warning::transitional manual release is building locally; migrate this caller to candidate IDs"
npm publish --tag "$NPM_DIST_TAG" --access public
Expand Down Expand Up @@ -474,6 +482,8 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ env.DISPATCH_VERSION }}
TAG_NEEDED: ${{ steps.release-metadata.outputs.tag-needed }}
RELEASE_NEEDED: ${{ steps.release-metadata.outputs.release-needed }}
run: |
set -euo pipefail

Expand All @@ -497,10 +507,29 @@ jobs:
prerelease_flag="--prerelease"
fi

if [ -z "$existing_tag_sha" ]; then
git tag "$release_tag" "$GITHUB_SHA"
git push origin "refs/tags/${release_tag}"
if [ "$TAG_NEEDED" = "true" ]; then
# Checkout deliberately leaves no credential in .git/config. Create
# the lightweight tag through the authenticated API instead of an
# unauthenticated git push, then verify the public ref before release.
if ! gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/tags/${release_tag}" -f sha="$GITHUB_SHA" >/dev/null; then
echo "::notice::Tag creation raced another writer; verifying the resulting ref"
fi
fi

gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \
--notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag}
git fetch --force --tags origin
published_tag_sha="$(git rev-parse -q --verify "refs/tags/${release_tag}^{commit}" || true)"
if [ "$published_tag_sha" != "$GITHUB_SHA" ]; then
echo "::error::${release_tag} resolved to ${published_tag_sha:-nothing}, not ${GITHUB_SHA}"
exit 1
fi

if [ "$RELEASE_NEEDED" = "true" ]; then
if ! gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \
--notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag}; then
echo "::notice::Release creation raced another writer; verifying the resulting release"
gh release view "$release_tag" --json tagName,targetCommitish >/dev/null
fi
else
echo "::notice::GitHub Release ${release_tag} already exists at the verified tag"
fi
Loading
Loading