Skip to content
Closed
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
46 changes: 44 additions & 2 deletions .github/workflows/node-github-release.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: node-github-release
run-name: GitHub release ${{ inputs.tag }}

on:
workflow_dispatch:
Expand All @@ -16,8 +17,8 @@ permissions:
contents: read

concurrency:
group: node-github-release
queue: max
group: node-github-release-${{ inputs.tag }}
Comment thread
mldangelo-oai marked this conversation as resolved.
cancel-in-progress: false

jobs:
release:
Expand Down Expand Up @@ -392,6 +393,47 @@ jobs:
printf 'make-latest=%s\n' "$make_latest"
} >> "$GITHUB_OUTPUT"

- name: Wait for older GitHub releases
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ steps.release.outputs.version }}
run: |
set -euo pipefail

for attempt in {1..120}; do
waiting=
active_runs="$(
gh api --paginate \
"repos/$GITHUB_REPOSITORY/actions/workflows/node-github-release.yml/runs?per_page=100" \
--jq '.workflow_runs[] | select(.status != "completed") | [.id, (.display_title // ""), .run_number] | @tsv'
Comment on lines +407 to +409

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop paginating completed release history on every poll

In a repository with a long release history, this invocation fetches every historical page before the local jq expression discards completed runs; checked gh api --help, which documents that --paginate requests all pages until none remain (GitHub CLI manual). Because this loop repeats up to 120 times at 15-second intervals, and the npm ordering loop uses the same pattern, blocked or concurrent releases can consume the workflow token's API quota and abort publication instead of waiting. Restrict the API queries to relevant statuses or a bounded recent window rather than paginating all completed history on every iteration.

Useful? React with 👍 / 👎.

)"
while IFS=$'\t' read -r run_id run_name run_number; do
[[ -n "$run_id" && "$run_id" != "$GITHUB_RUN_ID" ]] || continue
if [[ "$run_name" == "GitHub release npm-v"* ]]; then
candidate="${run_name#GitHub release npm-v}"
node sdk/typescript/scripts/release-automation.mjs \
require-increase "$RELEASE_VERSION" "$candidate" \
>/dev/null 2>&1 || continue
elif [[ ! "$run_number" =~ ^[0-9]+$ ||
"$run_number" -ge "$GITHUB_RUN_NUMBER" ]]; then
continue
fi
waiting="$run_id"
break
done <<< "$active_runs"

if [[ -z "$waiting" ]]; then
echo "No older GitHub release is still running."
break
fi
if [[ "$attempt" == 120 ]]; then
echo "Timed out waiting for older GitHub release run $waiting." >&2
exit 1
fi
sleep 15
done

- name: Publish GitHub Release and generated notes
shell: bash
env:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/node-release-cut.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ permissions:
contents: read

concurrency:
group: node-release-cut
queue: max
group: node-release-cut-${{ github.event.workflow_run.head_sha || github.sha }}
cancel-in-progress: false
Comment thread
mldangelo-oai marked this conversation as resolved.

jobs:
cut:
Expand Down
43 changes: 41 additions & 2 deletions .github/workflows/node-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ on:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}
queue: max
group: ${{ github.workflow }}-${{ github.ref }}
Comment thread
mldangelo-oai marked this conversation as resolved.
cancel-in-progress: false

jobs:
verify:
Expand Down Expand Up @@ -218,6 +218,7 @@ jobs:
permissions:
contents: read
id-token: write
actions: read

steps:
- name: Checkout release verification source
Expand Down Expand Up @@ -331,6 +332,44 @@ jobs:
"$GITHUB_SHA" \
"$GITHUB_REPOSITORY"

- name: Wait for older npm releases
if: needs.verify.outputs.mode == 'publish'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ needs.verify.outputs.version }}
run: |
set -euo pipefail

for attempt in {1..120}; do
waiting=
active_runs="$(
gh api --paginate \
"repos/$GITHUB_REPOSITORY/actions/workflows/node-release.yml/runs?per_page=100" \
--jq '.workflow_runs[] | select(.status != "completed") | [.id, (.head_branch // "")] | @tsv'
)"
Comment on lines +346 to +350

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close the race after checking older npm runs

When release-cut runs complete out of version order, a lower-version node-release can be dispatched immediately after this higher-version run captures active_runs. The higher run then leaves the loop, while the lower run can pass its initial npm-history validation before the higher publish; because neither run rechecks registry ordering before both execute npm publish --tag latest, the lower version can finish last and move latest backward (npm dist-tag documentation). Fresh evidence for the earlier cross-tag serialization concern is that the new polling guard is only a snapshot rather than a lock held through publication; use a durable queue/lock or update latest safely from authoritative state.

Useful? React with 👍 / 👎.

while IFS=$'\t' read -r run_id ref_name; do
[[ -n "$run_id" && "$run_id" != "$GITHUB_RUN_ID" ]] || continue
[[ "$ref_name" == npm-v* ]] || continue
candidate="${ref_name#npm-v}"
if node sdk/typescript/scripts/release-automation.mjs \
require-increase "$RELEASE_VERSION" "$candidate" >/dev/null 2>&1; then
waiting="$run_id"
break
fi
done <<< "$active_runs"

if [[ -z "$waiting" ]]; then
echo "No older npm release is still running."
break
fi
if [[ "$attempt" == 120 ]]; then
echo "Timed out waiting for older npm release run $waiting." >&2
exit 1
fi
sleep 15
done

- name: Revalidate protected release tag
if: needs.verify.outputs.mode == 'publish'
shell: bash
Expand Down
133 changes: 122 additions & 11 deletions sdk/typescript/tests-ts/release-automation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2046,13 +2046,125 @@ describe("GitHub release workflow safeguards", () => {
);
});

test("durably queues every release-cut and protected publishing run", () => {
expect(releaseCutWorkflow).toMatch(
/concurrency:\s*\n\s+group: node-release-cut\s*\n\s+queue: max/u,
test("keeps distinct release commits and tags in separate concurrency groups", () => {
expect(releaseCutWorkflow).toContain(
"group: node-release-cut-${{ github.event.workflow_run.head_sha || github.sha }}",
);
expect(protectedReleaseWorkflow).toMatch(
/concurrency:\s*\n\s+group: \$\{\{ github\.workflow \}\}\s*\n\s+queue: max/u,
expect(protectedReleaseWorkflow).toContain(
"group: ${{ github.workflow }}-${{ github.ref }}",
);
expect(releaseCutWorkflow).toContain("cancel-in-progress: false");
expect(protectedReleaseWorkflow).toContain("cancel-in-progress: false");
expect(releaseCutWorkflow).not.toMatch(/^\s+queue:/mu);
expect(protectedReleaseWorkflow).not.toMatch(/^\s+queue:/mu);
});

test("waits for older npm publishers without dropping pending release tags", () => {
const ordering = workflowStepShell(
protectedReleaseWorkflow,
"Wait for older npm releases",
);
expect(ordering).toContain(
"actions/workflows/node-release.yml/runs?per_page=100",
);
expect(ordering).toContain(
'require-increase "$RELEASE_VERSION" "$candidate"',
);
expect(ordering).toContain("sleep 15");
expect(protectedReleaseWorkflow).toContain(" actions: read");
expect(
protectedReleaseWorkflow.indexOf("- name: Wait for older npm releases"),
).toBeLessThan(
protectedReleaseWorkflow.indexOf(
"- name: Revalidate protected release tag",
),
);
expect(
protectedReleaseWorkflow.indexOf(
"- name: Revalidate protected release tag",
),
).toBeLessThan(
protectedReleaseWorkflow.indexOf("- name: Publish initial npm release"),
);

const failedLookup = spawnSync(
"bash",
["-c", `gh() { return 17; }\n${ordering}`],
{
encoding: "utf8",
env: {
...process.env,
GITHUB_REPOSITORY: "test/codex-security",
GITHUB_RUN_ID: "1",
RELEASE_VERSION: "0.2.0",
},
},
);
expect(failedLookup.status).toBe(17);
expect(failedLookup.stdout).not.toContain("No older npm release");
});

test("publishes GitHub releases in version order without dropping pending tags", () => {
expect(githubReleaseWorkflow).toContain(
"run-name: GitHub release ${{ inputs.tag }}",
);
const ordering = workflowStepShell(
githubReleaseWorkflow,
"Wait for older GitHub releases",
);
expect(ordering).toContain(
"actions/workflows/node-github-release.yml/runs?per_page=100",
);
expect(ordering).toContain(
'require-increase "$RELEASE_VERSION" "$candidate"',
);

const root = mkdtempSync(join(tmpdir(), "codex-security-github-order-"));
const mocks = [
"gh() {",
' if [[ ! -e "$RELEASE_RUN_CHECKED" ]]; then',
' touch "$RELEASE_RUN_CHECKED"',
" printf '7\\tGitHub release npm-v0.1.0\\t7\\n'",
" fi",
"}",
'sleep() { printf "waited %s\\n" "$1"; }',
].join("\n");
try {
const ordered = spawnSync("bash", ["-c", `${mocks}\n${ordering}`], {
encoding: "utf8",
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
env: {
...process.env,
GITHUB_REPOSITORY: "test/codex-security",
GITHUB_RUN_ID: "8",
GITHUB_RUN_NUMBER: "8",
RELEASE_RUN_CHECKED: join(root, "checked"),
RELEASE_VERSION: "0.2.0",
},
});
expect(ordered.status).toBe(0);
expect(ordered.stdout).toContain("waited 15");
expect(ordered.stdout).toContain("No older GitHub release");
} finally {
rmSync(root, { recursive: true, force: true });
}

const failedLookup = spawnSync(
"bash",
["-c", `gh() { return 17; }\n${ordering}`],
{
encoding: "utf8",
env: {
...process.env,
GITHUB_REPOSITORY: "test/codex-security",
GITHUB_RUN_ID: "8",
GITHUB_RUN_NUMBER: "8",
RELEASE_VERSION: "0.2.0",
},
},
);
expect(failedLookup.status).toBe(17);
expect(failedLookup.stdout).not.toContain("No older GitHub release");
});

test("dispatches GitHub releases after publishing with isolated permissions", () => {
Expand Down Expand Up @@ -2109,13 +2221,12 @@ describe("GitHub release workflow safeguards", () => {
]);
});

test("serializes every GitHub release and historical backfill", () => {
expect(githubReleaseWorkflow).toMatch(
/concurrency:\s*\n\s+group: node-github-release\s*\n\s+queue: max/u,
);
expect(githubReleaseWorkflow).not.toContain(
"group: node-github-release-${{",
test("preserves GitHub releases and historical backfills for distinct tags", () => {
expect(githubReleaseWorkflow).toContain(
"group: node-github-release-${{ inputs.tag }}",
);
expect(githubReleaseWorkflow).toContain("cancel-in-progress: false");
expect(githubReleaseWorkflow).not.toMatch(/^\s+queue:/mu);
});

test("runs manually dispatched GitHub releases from trusted main", () => {
Expand Down
Loading